diff --git a/packages/mcp/src/server.spec.ts b/packages/mcp/src/server.spec.ts index 44a5bba..9396f5c 100644 --- a/packages/mcp/src/server.spec.ts +++ b/packages/mcp/src/server.spec.ts @@ -180,6 +180,168 @@ describe("MCP Server", () => { expect(text).toContain("FacturaElectronica"); }); + it("should create an invoice with a single exempt line item (no tax)", async () => { + const result = await client.callTool({ + name: "create_invoice", + arguments: { + emisor: { + nombre: "Exenta S.A.", + identificacion: { tipo: "02", numero: "3109876543" }, + correoElectronico: "exenta@empresa.com", + }, + receptor: { nombre: "Cliente Exento" }, + codigoActividad: "620100", + lineItems: [ + { + codigoCabys: "8310100000000", + cantidad: 2, + unidadMedida: "Unid", + detalle: "Producto exento de impuesto", + precioUnitario: 5000, + }, + ], + }, + }); + + expect(result.isError).toBeFalsy(); + const text = getTextContent(result.content as { type: string; text: string }[]); + expect(text).toContain("Clave:"); + expect(text).toContain(" 0", async () => { + const result = await client.callTool({ + name: "create_invoice", + arguments: { + emisor: { + nombre: "Gravada S.A.", + identificacion: { tipo: "02", numero: "3101234567" }, + correoElectronico: "gravada@empresa.com", + }, + receptor: { nombre: "Cliente Gravado" }, + codigoActividad: "620100", + lineItems: [ + { + codigoCabys: "8310100000000", + cantidad: 1, + unidadMedida: "Sp", + detalle: "Servicio gravado", + precioUnitario: 10000, + esServicio: true, + impuesto: [{ codigo: "01", codigoTarifa: "08", tarifa: 13 }], + }, + ], + }, + }); + + expect(result.isError).toBeFalsy(); + const text = getTextContent(result.content as { type: string; text: string }[]); + expect(text).toContain("Tax: 1300"); + expect(text).toContain("Total: 11300"); + expect(text).toContain("1300"); + }); + + it("should return error when emisor is missing", async () => { + const result = await client.callTool({ + name: "create_invoice", + arguments: { + receptor: { nombre: "Cliente" }, + codigoActividad: "620100", + lineItems: [ + { + codigoCabys: "8310100000000", + cantidad: 1, + unidadMedida: "Unid", + detalle: "Item", + precioUnitario: 1000, + }, + ], + }, + }); + + expect(result.isError).toBe(true); + const text = getTextContent(result.content as { type: string; text: string }[]); + expect(text.toLowerCase()).toContain("error"); + }); + + it("should create an invoice with multiple line items", async () => { + const result = await client.callTool({ + name: "create_invoice", + arguments: { + emisor: { + nombre: "Multi S.A.", + identificacion: { tipo: "02", numero: "3101234567" }, + correoElectronico: "multi@empresa.com", + }, + receptor: { nombre: "Cliente Multi" }, + codigoActividad: "620100", + lineItems: [ + { + codigoCabys: "8310100000000", + cantidad: 1, + unidadMedida: "Sp", + detalle: "Consultoria estrategica", + precioUnitario: 50000, + esServicio: true, + }, + { + codigoCabys: "4321000000000", + cantidad: 3, + unidadMedida: "Unid", + detalle: "Licencia de software", + precioUnitario: 20000, + }, + ], + }, + }); + + expect(result.isError).toBeFalsy(); + const text = getTextContent(result.content as { type: string; text: string }[]); + expect(text).toContain("Consultoria estrategica"); + expect(text).toContain("Licencia de software"); + expect(text).toContain("Total: 110000"); + }); + + it("should create an invoice with a discount on a line item", async () => { + const result = await client.callTool({ + name: "create_invoice", + arguments: { + emisor: { + nombre: "Descuento S.A.", + identificacion: { tipo: "02", numero: "3101234567" }, + correoElectronico: "desc@empresa.com", + }, + receptor: { nombre: "Cliente Descuento" }, + codigoActividad: "620100", + lineItems: [ + { + codigoCabys: "8310100000000", + cantidad: 1, + unidadMedida: "Unid", + detalle: "Producto con descuento", + precioUnitario: 100000, + descuento: [ + { + montoDescuento: 10000, + naturalezaDescuento: "Descuento por volumen", + }, + ], + }, + ], + }, + }); + + expect(result.isError).toBeFalsy(); + const text = getTextContent(result.content as { type: string; text: string }[]); + expect(text).toContain("Descuento por volumen"); + expect(text).toContain("10000"); + expect(text).toContain("Total: 80000"); + expect(text).toContain("10000"); + }); + it("should return error for missing required fields", async () => { const result = await client.callTool({ name: "create_invoice", diff --git a/packages/sdk/src/api/http-client.spec.ts b/packages/sdk/src/api/http-client.spec.ts index 3166d70..b069cd2 100644 --- a/packages/sdk/src/api/http-client.spec.ts +++ b/packages/sdk/src/api/http-client.spec.ts @@ -211,5 +211,68 @@ describe("HttpClient", () => { expect(response.headers.get("Location")).toBe("/recepcion/12345"); }); + + it("returns text for XML content-type responses", async () => { + const xmlBody = "ok"; + const mockFetch = vi.fn().mockResolvedValue({ + ok: true, + status: 200, + statusText: "OK", + headers: new Headers({ "Content-Type": "application/xml" }), + text: () => Promise.resolve(xmlBody), + } as Response); + const client = createHttpClient(mockFetch); + + const response = await client.get("/test"); + + expect(response.data).toBe(xmlBody); + }); + + it("returns undefined for empty 204 No Content responses", async () => { + const mockFetch = vi.fn().mockResolvedValue({ + ok: true, + status: 204, + statusText: "No Content", + headers: new Headers(), + text: () => Promise.resolve(""), + } as Response); + const client = createHttpClient(mockFetch); + + const response = await client.get("/test"); + + expect(response.status).toBe(204); + expect(response.data).toBeUndefined(); + }); + + it("returns text for plain text non-JSON responses", async () => { + const mockFetch = vi.fn().mockResolvedValue({ + ok: true, + status: 200, + statusText: "OK", + headers: new Headers({ "Content-Type": "text/plain" }), + text: () => Promise.resolve("plain text response"), + } as Response); + const client = createHttpClient(mockFetch); + + const response = await client.get("/test"); + + expect(response.data).toBe("plain text response"); + }); + + it("parses JSON from text when content-type is not application/json", async () => { + const jsonData = { key: "value", count: 42 }; + const mockFetch = vi.fn().mockResolvedValue({ + ok: true, + status: 200, + statusText: "OK", + headers: new Headers({ "Content-Type": "text/plain" }), + text: () => Promise.resolve(JSON.stringify(jsonData)), + } as Response); + const client = createHttpClient(mockFetch); + + const response = await client.get("/test"); + + expect(response.data).toEqual(jsonData); + }); }); }); diff --git a/packages/sdk/src/documents/shared-xml-helpers.spec.ts b/packages/sdk/src/documents/shared-xml-helpers.spec.ts new file mode 100644 index 0000000..e50870f --- /dev/null +++ b/packages/sdk/src/documents/shared-xml-helpers.spec.ts @@ -0,0 +1,810 @@ +/** + * Tests for shared XML mapping helpers — covers untested optional paths. + */ + +import { describe, it, expect } from "vitest"; +import { + buildEmisorXml, + buildReceptorXml, + buildResumenFacturaXml, + buildOtrosCargosXml, + buildInformacionReferenciaXml, + buildOtrosXml, + buildStandardDocumentBody, +} from "./shared-xml-helpers.js"; +import type { + Emisor, + Receptor, + ResumenFactura, + InformacionReferencia, + OtroContenido, + LineaDetalle, +} from "@hacienda-cr/shared"; +import type { CommonDocumentFields } from "./shared-xml-helpers.js"; + +// --------------------------------------------------------------------------- +// Minimal fixture helpers +// --------------------------------------------------------------------------- + +function minimalEmisor(overrides: Partial = {}): Emisor { + return { + nombre: "Empresa Test S.A.", + identificacion: { tipo: "02", numero: "3101234567" }, + correoElectronico: "test@example.com", + ...overrides, + }; +} + +function minimalReceptor(overrides: Partial = {}): Receptor { + return { + nombre: "Cliente Test", + ...overrides, + }; +} + +function minimalResumen(overrides: Partial = {}): ResumenFactura { + return { + totalServGravados: 100000, + totalServExentos: 0, + totalMercanciasGravadas: 0, + totalMercanciasExentas: 0, + totalGravado: 100000, + totalExento: 0, + totalVenta: 100000, + totalDescuentos: 0, + totalVentaNeta: 100000, + totalImpuesto: 13000, + totalComprobante: 113000, + ...overrides, + }; +} + +function minimalLineaDetalle(): LineaDetalle { + return { + numeroLinea: 1, + codigoCabys: "4321000000000", + cantidad: 1, + unidadMedida: "Sp", + detalle: "Servicio", + precioUnitario: 1000, + montoTotal: 1000, + subTotal: 1000, + montoTotalLinea: 1000, + } as LineaDetalle; +} + +function minimalDocumentFields( + overrides: Partial = {}, +): CommonDocumentFields { + return { + clave: "50601012600310123456700100001010000000001199999999", + codigoActividad: "620100", + numeroConsecutivo: "00100001010000000001", + fechaEmision: "2025-07-27T10:30:00-06:00", + emisor: minimalEmisor(), + condicionVenta: "01", + medioPago: ["01"], + detalleServicio: [minimalLineaDetalle()], + resumenFactura: minimalResumen(), + ...overrides, + }; +} + +// --------------------------------------------------------------------------- +// buildEmisorXml +// --------------------------------------------------------------------------- + +describe("buildEmisorXml", () => { + it("should include Fax when present", () => { + const result = buildEmisorXml( + minimalEmisor({ fax: { codigoPais: "506", numTelefono: "22224444" } }), + ); + expect(result.Fax).toEqual({ CodigoPais: "506", NumTelefono: "22224444" }); + }); + + it("should not include Fax when absent", () => { + const result = buildEmisorXml(minimalEmisor()); + expect(result).not.toHaveProperty("Fax"); + }); + + it("should include Telefono when present", () => { + const result = buildEmisorXml( + minimalEmisor({ telefono: { codigoPais: "506", numTelefono: "88887777" } }), + ); + expect(result.Telefono).toEqual({ CodigoPais: "506", NumTelefono: "88887777" }); + }); + + it("should not include Telefono when absent", () => { + const result = buildEmisorXml(minimalEmisor()); + expect(result).not.toHaveProperty("Telefono"); + }); + + it("should include NombreComercial when present", () => { + const result = buildEmisorXml(minimalEmisor({ nombreComercial: "MiMarca" })); + expect(result.NombreComercial).toBe("MiMarca"); + }); + + it("should not include NombreComercial when absent", () => { + const result = buildEmisorXml(minimalEmisor()); + expect(result).not.toHaveProperty("NombreComercial"); + }); + + it("should include Ubicacion with Barrio when present", () => { + const result = buildEmisorXml( + minimalEmisor({ + ubicacion: { + provincia: "1", + canton: "01", + distrito: "01", + barrio: "02", + }, + }), + ); + expect(result.Ubicacion).toEqual({ + Provincia: "1", + Canton: "01", + Distrito: "01", + Barrio: "02", + }); + }); + + it("should include Ubicacion with OtrasSenas when present", () => { + const result = buildEmisorXml( + minimalEmisor({ + ubicacion: { + provincia: "2", + canton: "03", + distrito: "04", + otrasSenas: "Frente al parque", + }, + }), + ); + expect(result.Ubicacion).toEqual({ + Provincia: "2", + Canton: "03", + Distrito: "04", + OtrasSenas: "Frente al parque", + }); + }); + + it("should include Ubicacion with both Barrio and OtrasSenas", () => { + const result = buildEmisorXml( + minimalEmisor({ + ubicacion: { + provincia: "1", + canton: "01", + distrito: "01", + barrio: "05", + otrasSenas: "200m norte de la iglesia", + }, + }), + ); + const ubicacion = result.Ubicacion as Record; + expect(ubicacion.Barrio).toBe("05"); + expect(ubicacion.OtrasSenas).toBe("200m norte de la iglesia"); + }); + + it("should omit Ubicacion entirely when absent", () => { + const result = buildEmisorXml(minimalEmisor()); + expect(result).not.toHaveProperty("Ubicacion"); + }); + + it("should include all optional fields together", () => { + const result = buildEmisorXml( + minimalEmisor({ + nombreComercial: "Brand", + ubicacion: { + provincia: "1", + canton: "01", + distrito: "01", + barrio: "01", + otrasSenas: "Calle 1", + }, + telefono: { codigoPais: "506", numTelefono: "11112222" }, + fax: { codigoPais: "506", numTelefono: "33334444" }, + }), + ); + expect(result.NombreComercial).toBe("Brand"); + expect(result).toHaveProperty("Ubicacion"); + expect(result).toHaveProperty("Telefono"); + expect(result).toHaveProperty("Fax"); + expect(result.Nombre).toBe("Empresa Test S.A."); + expect(result.CorreoElectronico).toBe("test@example.com"); + }); +}); + +// --------------------------------------------------------------------------- +// buildReceptorXml +// --------------------------------------------------------------------------- + +describe("buildReceptorXml", () => { + it("should include Fax when present", () => { + const result = buildReceptorXml( + minimalReceptor({ fax: { codigoPais: "506", numTelefono: "55556666" } }), + ); + expect(result.Fax).toEqual({ CodigoPais: "506", NumTelefono: "55556666" }); + }); + + it("should not include Fax when absent", () => { + const result = buildReceptorXml(minimalReceptor()); + expect(result).not.toHaveProperty("Fax"); + }); + + it("should include IdentificacionExtranjero when present", () => { + const result = buildReceptorXml( + minimalReceptor({ identificacionExtranjero: "US-EIN-99-1234567" }), + ); + expect(result.IdentificacionExtranjero).toBe("US-EIN-99-1234567"); + }); + + it("should not include IdentificacionExtranjero when absent", () => { + const result = buildReceptorXml(minimalReceptor()); + expect(result).not.toHaveProperty("IdentificacionExtranjero"); + }); + + it("should include NombreComercial when present", () => { + const result = buildReceptorXml(minimalReceptor({ nombreComercial: "Tienda XYZ" })); + expect(result.NombreComercial).toBe("Tienda XYZ"); + }); + + it("should not include NombreComercial when absent", () => { + const result = buildReceptorXml(minimalReceptor()); + expect(result).not.toHaveProperty("NombreComercial"); + }); + + it("should include Ubicacion with Barrio and OtrasSenas", () => { + const result = buildReceptorXml( + minimalReceptor({ + ubicacion: { + provincia: "3", + canton: "02", + distrito: "05", + barrio: "03", + otrasSenas: "Edificio azul", + }, + }), + ); + expect(result.Ubicacion).toEqual({ + Provincia: "3", + Canton: "02", + Distrito: "05", + Barrio: "03", + OtrasSenas: "Edificio azul", + }); + }); + + it("should include Ubicacion without optional subfields", () => { + const result = buildReceptorXml( + minimalReceptor({ + ubicacion: { provincia: "1", canton: "01", distrito: "01" }, + }), + ); + const ubicacion = result.Ubicacion as Record; + expect(ubicacion).not.toHaveProperty("Barrio"); + expect(ubicacion).not.toHaveProperty("OtrasSenas"); + }); + + it("should include Telefono when present", () => { + const result = buildReceptorXml( + minimalReceptor({ telefono: { codigoPais: "506", numTelefono: "77778888" } }), + ); + expect(result.Telefono).toEqual({ CodigoPais: "506", NumTelefono: "77778888" }); + }); + + it("should include CorreoElectronico when present", () => { + const result = buildReceptorXml(minimalReceptor({ correoElectronico: "cliente@test.com" })); + expect(result.CorreoElectronico).toBe("cliente@test.com"); + }); + + it("should not include CorreoElectronico when absent", () => { + const result = buildReceptorXml(minimalReceptor()); + expect(result).not.toHaveProperty("CorreoElectronico"); + }); + + it("should include Identificacion when present", () => { + const result = buildReceptorXml( + minimalReceptor({ identificacion: { tipo: "01", numero: "101230456" } }), + ); + expect(result.Identificacion).toEqual({ Tipo: "01", Numero: "101230456" }); + }); + + it("should not include Identificacion when absent", () => { + const result = buildReceptorXml(minimalReceptor()); + expect(result).not.toHaveProperty("Identificacion"); + }); + + it("should handle all optional fields together", () => { + const result = buildReceptorXml( + minimalReceptor({ + identificacion: { tipo: "02", numero: "3109876543" }, + identificacionExtranjero: "PA-RUC-555", + nombreComercial: "Import Co", + ubicacion: { + provincia: "7", + canton: "01", + distrito: "01", + barrio: "01", + otrasSenas: "Zona franca", + }, + telefono: { codigoPais: "507", numTelefono: "12345678" }, + fax: { codigoPais: "507", numTelefono: "87654321" }, + correoElectronico: "import@co.pa", + }), + ); + expect(result).toHaveProperty("Identificacion"); + expect(result).toHaveProperty("IdentificacionExtranjero"); + expect(result).toHaveProperty("NombreComercial"); + expect(result).toHaveProperty("Ubicacion"); + expect(result).toHaveProperty("Telefono"); + expect(result).toHaveProperty("Fax"); + expect(result).toHaveProperty("CorreoElectronico"); + }); +}); + +// --------------------------------------------------------------------------- +// buildResumenFacturaXml +// --------------------------------------------------------------------------- + +describe("buildResumenFacturaXml", () => { + it("should include CodigoTipoMoneda when present", () => { + const result = buildResumenFacturaXml( + minimalResumen({ + codigoTipoMoneda: { codigoMoneda: "USD" as never, tipoCambio: 530.5 }, + }), + ); + expect(result.CodigoTipoMoneda).toEqual({ CodigoMoneda: "USD", TipoCambio: 530.5 }); + }); + + it("should not include CodigoTipoMoneda when absent", () => { + const result = buildResumenFacturaXml(minimalResumen()); + expect(result).not.toHaveProperty("CodigoTipoMoneda"); + }); + + it("should include TotalServExonerado when > 0", () => { + const result = buildResumenFacturaXml(minimalResumen({ totalServExonerado: 50000 })); + expect(result.TotalServExonerado).toBe(50000); + }); + + it("should not include TotalServExonerado when 0", () => { + const result = buildResumenFacturaXml(minimalResumen({ totalServExonerado: 0 })); + expect(result).not.toHaveProperty("TotalServExonerado"); + }); + + it("should not include TotalServExonerado when undefined", () => { + const result = buildResumenFacturaXml(minimalResumen()); + expect(result).not.toHaveProperty("TotalServExonerado"); + }); + + it("should include TotalMercExonerada when > 0", () => { + const result = buildResumenFacturaXml(minimalResumen({ totalMercExonerada: 75000 })); + expect(result.TotalMercExonerada).toBe(75000); + }); + + it("should not include TotalMercExonerada when 0", () => { + const result = buildResumenFacturaXml(minimalResumen({ totalMercExonerada: 0 })); + expect(result).not.toHaveProperty("TotalMercExonerada"); + }); + + it("should not include TotalMercExonerada when undefined", () => { + const result = buildResumenFacturaXml(minimalResumen()); + expect(result).not.toHaveProperty("TotalMercExonerada"); + }); + + it("should include TotalExonerado when > 0", () => { + const result = buildResumenFacturaXml(minimalResumen({ totalExonerado: 125000 })); + expect(result.TotalExonerado).toBe(125000); + }); + + it("should not include TotalExonerado when 0", () => { + const result = buildResumenFacturaXml(minimalResumen({ totalExonerado: 0 })); + expect(result).not.toHaveProperty("TotalExonerado"); + }); + + it("should not include TotalExonerado when undefined", () => { + const result = buildResumenFacturaXml(minimalResumen()); + expect(result).not.toHaveProperty("TotalExonerado"); + }); + + it("should include TotalIVADevuelto when > 0", () => { + const result = buildResumenFacturaXml(minimalResumen({ totalIVADevuelto: 5000 })); + expect(result.TotalIVADevuelto).toBe(5000); + }); + + it("should not include TotalIVADevuelto when 0", () => { + const result = buildResumenFacturaXml(minimalResumen({ totalIVADevuelto: 0 })); + expect(result).not.toHaveProperty("TotalIVADevuelto"); + }); + + it("should not include TotalIVADevuelto when undefined", () => { + const result = buildResumenFacturaXml(minimalResumen()); + expect(result).not.toHaveProperty("TotalIVADevuelto"); + }); + + it("should include TotalOtrosCargos when > 0", () => { + const result = buildResumenFacturaXml(minimalResumen({ totalOtrosCargos: 2500 })); + expect(result.TotalOtrosCargos).toBe(2500); + }); + + it("should not include TotalOtrosCargos when 0", () => { + const result = buildResumenFacturaXml(minimalResumen({ totalOtrosCargos: 0 })); + expect(result).not.toHaveProperty("TotalOtrosCargos"); + }); + + it("should not include TotalOtrosCargos when undefined", () => { + const result = buildResumenFacturaXml(minimalResumen()); + expect(result).not.toHaveProperty("TotalOtrosCargos"); + }); + + it("should always include required summary totals", () => { + const result = buildResumenFacturaXml(minimalResumen()); + expect(result.TotalServGravados).toBe(100000); + expect(result.TotalServExentos).toBe(0); + expect(result.TotalMercanciasGravadas).toBe(0); + expect(result.TotalMercanciasExentas).toBe(0); + expect(result.TotalGravado).toBe(100000); + expect(result.TotalExento).toBe(0); + expect(result.TotalVenta).toBe(100000); + expect(result.TotalDescuentos).toBe(0); + expect(result.TotalVentaNeta).toBe(100000); + expect(result.TotalImpuesto).toBe(13000); + expect(result.TotalComprobante).toBe(113000); + }); +}); + +// --------------------------------------------------------------------------- +// buildOtrosCargosXml +// --------------------------------------------------------------------------- + +describe("buildOtrosCargosXml", () => { + it("should map required fields", () => { + const result = buildOtrosCargosXml([ + { tipoDocumento: "06", detalle: "Servicio de flete", montoOtroCargo: 5000 }, + ]); + expect(result.OtroCargo).toEqual([ + { TipoDocumento: "06", Detalle: "Servicio de flete", MontoOtroCargo: 5000 }, + ]); + }); + + it("should include NumeroIdentidadTercero when present", () => { + const result = buildOtrosCargosXml([ + { + tipoDocumento: "06", + numeroIdentidadTercero: "3101999999", + detalle: "Cargo tercero", + montoOtroCargo: 3000, + }, + ]); + const cargo = (result.OtroCargo as Record[])[0]; + expect(cargo.NumeroIdentidadTercero).toBe("3101999999"); + }); + + it("should not include NumeroIdentidadTercero when absent", () => { + const result = buildOtrosCargosXml([ + { tipoDocumento: "06", detalle: "Cargo simple", montoOtroCargo: 1000 }, + ]); + const cargo = (result.OtroCargo as Record[])[0]; + expect(cargo).not.toHaveProperty("NumeroIdentidadTercero"); + }); + + it("should include NombreTercero when present", () => { + const result = buildOtrosCargosXml([ + { + tipoDocumento: "01", + nombreTercero: "Transportes ABC", + detalle: "Flete internacional", + montoOtroCargo: 15000, + }, + ]); + const cargo = (result.OtroCargo as Record[])[0]; + expect(cargo.NombreTercero).toBe("Transportes ABC"); + }); + + it("should not include NombreTercero when absent", () => { + const result = buildOtrosCargosXml([ + { tipoDocumento: "01", detalle: "Cargo", montoOtroCargo: 500 }, + ]); + const cargo = (result.OtroCargo as Record[])[0]; + expect(cargo).not.toHaveProperty("NombreTercero"); + }); + + it("should include Porcentaje when present", () => { + const result = buildOtrosCargosXml([ + { + tipoDocumento: "02", + detalle: "Cargo porcentual", + porcentaje: 10, + montoOtroCargo: 10000, + }, + ]); + const cargo = (result.OtroCargo as Record[])[0]; + expect(cargo.Porcentaje).toBe(10); + }); + + it("should include Porcentaje when zero", () => { + const result = buildOtrosCargosXml([ + { + tipoDocumento: "02", + detalle: "Cargo con porcentaje cero", + porcentaje: 0, + montoOtroCargo: 0, + }, + ]); + const cargo = (result.OtroCargo as Record[])[0]; + expect(cargo.Porcentaje).toBe(0); + }); + + it("should not include Porcentaje when undefined", () => { + const result = buildOtrosCargosXml([ + { tipoDocumento: "02", detalle: "Sin porcentaje", montoOtroCargo: 2000 }, + ]); + const cargo = (result.OtroCargo as Record[])[0]; + expect(cargo).not.toHaveProperty("Porcentaje"); + }); + + it("should include all optional fields together", () => { + const result = buildOtrosCargosXml([ + { + tipoDocumento: "07", + numeroIdentidadTercero: "105550123", + nombreTercero: "Juan Perez", + detalle: "Comision completa", + porcentaje: 5, + montoOtroCargo: 2500, + }, + ]); + const cargo = (result.OtroCargo as Record[])[0]; + expect(cargo.NumeroIdentidadTercero).toBe("105550123"); + expect(cargo.NombreTercero).toBe("Juan Perez"); + expect(cargo.Porcentaje).toBe(5); + expect(cargo.Detalle).toBe("Comision completa"); + expect(cargo.MontoOtroCargo).toBe(2500); + }); + + it("should handle multiple cargos", () => { + const result = buildOtrosCargosXml([ + { tipoDocumento: "01", detalle: "Cargo 1", montoOtroCargo: 1000 }, + { tipoDocumento: "02", detalle: "Cargo 2", montoOtroCargo: 2000, porcentaje: 5 }, + ]); + const cargos = result.OtroCargo as Record[]; + expect(cargos).toHaveLength(2); + expect(cargos[0].TipoDocumento).toBe("01"); + expect(cargos[1].Porcentaje).toBe(5); + }); +}); + +// --------------------------------------------------------------------------- +// buildInformacionReferenciaXml +// --------------------------------------------------------------------------- + +describe("buildInformacionReferenciaXml", () => { + it("should map a single reference", () => { + const refs: InformacionReferencia[] = [ + { + tipoDoc: "01" as never, + numero: "50601012500310123456700100001010000000099199999999", + fechaEmision: "2025-06-15T08:00:00-06:00", + codigo: "01" as never, + razon: "Correccion del monto", + }, + ]; + const result = buildInformacionReferenciaXml(refs); + expect(result).toEqual([ + { + TipoDoc: "01", + Numero: "50601012500310123456700100001010000000099199999999", + FechaEmision: "2025-06-15T08:00:00-06:00", + Codigo: "01", + Razon: "Correccion del monto", + }, + ]); + }); + + it("should map multiple references", () => { + const refs: InformacionReferencia[] = [ + { + tipoDoc: "01" as never, + numero: "clave-001", + fechaEmision: "2025-01-01T00:00:00-06:00", + codigo: "01" as never, + razon: "Razon 1", + }, + { + tipoDoc: "03" as never, + numero: "clave-002", + fechaEmision: "2025-02-01T00:00:00-06:00", + codigo: "02" as never, + razon: "Razon 2", + }, + ]; + const result = buildInformacionReferenciaXml(refs); + expect(result).toHaveLength(2); + expect(result[0].TipoDoc).toBe("01"); + expect(result[1].TipoDoc).toBe("03"); + expect(result[1].Codigo).toBe("02"); + }); + + it("should preserve all fields exactly", () => { + const refs: InformacionReferencia[] = [ + { + tipoDoc: "14" as never, + numero: "ABC-123", + fechaEmision: "2025-12-31T23:59:59-06:00", + codigo: "99" as never, + razon: "Otro motivo de referencia", + }, + ]; + const result = buildInformacionReferenciaXml(refs); + expect(result[0]).toEqual({ + TipoDoc: "14", + Numero: "ABC-123", + FechaEmision: "2025-12-31T23:59:59-06:00", + Codigo: "99", + Razon: "Otro motivo de referencia", + }); + }); +}); + +// --------------------------------------------------------------------------- +// buildOtrosXml +// --------------------------------------------------------------------------- + +describe("buildOtrosXml", () => { + it("should map a single OtroContenido", () => { + const otros: OtroContenido[] = [{ contenido: "valor" }]; + const result = buildOtrosXml(otros); + expect(result).toEqual({ + OtroContenido: [{ "#text": "valor" }], + }); + }); + + it("should map multiple OtroContenido entries", () => { + const otros: OtroContenido[] = [ + { contenido: "contenido-1" }, + { contenido: "contenido-2" }, + { contenido: "contenido-3" }, + ]; + const result = buildOtrosXml(otros); + const items = result.OtroContenido as Record[]; + expect(items).toHaveLength(3); + expect(items[0]["#text"]).toBe("contenido-1"); + expect(items[2]["#text"]).toBe("contenido-3"); + }); +}); + +// --------------------------------------------------------------------------- +// buildStandardDocumentBody +// --------------------------------------------------------------------------- + +describe("buildStandardDocumentBody", () => { + it("should include required fields", () => { + const result = buildStandardDocumentBody(minimalDocumentFields()); + expect(result.Clave).toBe("50601012600310123456700100001010000000001199999999"); + expect(result.CodigoActividad).toBe("620100"); + expect(result.NumeroConsecutivo).toBe("00100001010000000001"); + expect(result.FechaEmision).toBe("2025-07-27T10:30:00-06:00"); + expect(result).toHaveProperty("Emisor"); + expect(result.CondicionVenta).toBe("01"); + expect(result.MedioPago).toEqual(["01"]); + expect(result).toHaveProperty("DetalleServicio"); + expect(result).toHaveProperty("ResumenFactura"); + }); + + it("should not include Receptor when absent", () => { + const result = buildStandardDocumentBody(minimalDocumentFields()); + expect(result).not.toHaveProperty("Receptor"); + }); + + it("should include Receptor when present", () => { + const result = buildStandardDocumentBody( + minimalDocumentFields({ + receptor: minimalReceptor({ identificacion: { tipo: "01", numero: "101110222" } }), + }), + ); + expect(result).toHaveProperty("Receptor"); + const receptor = result.Receptor as Record; + expect(receptor.Nombre).toBe("Cliente Test"); + }); + + it("should not include PlazoCredito when absent", () => { + const result = buildStandardDocumentBody(minimalDocumentFields()); + expect(result).not.toHaveProperty("PlazoCredito"); + }); + + it("should include PlazoCredito when present", () => { + const result = buildStandardDocumentBody(minimalDocumentFields({ plazoCredito: "60" })); + expect(result.PlazoCredito).toBe("60"); + }); + + it("should not include OtrosCargos when absent", () => { + const result = buildStandardDocumentBody(minimalDocumentFields()); + expect(result).not.toHaveProperty("OtrosCargos"); + }); + + it("should not include OtrosCargos when empty array", () => { + const result = buildStandardDocumentBody(minimalDocumentFields({ otrosCargos: [] })); + expect(result).not.toHaveProperty("OtrosCargos"); + }); + + it("should include OtrosCargos when present", () => { + const result = buildStandardDocumentBody( + minimalDocumentFields({ + otrosCargos: [{ tipoDocumento: "06", detalle: "Flete", montoOtroCargo: 5000 }], + }), + ); + expect(result).toHaveProperty("OtrosCargos"); + }); + + it("should not include InformacionReferencia when absent", () => { + const result = buildStandardDocumentBody(minimalDocumentFields()); + expect(result).not.toHaveProperty("InformacionReferencia"); + }); + + it("should not include InformacionReferencia when empty array", () => { + const result = buildStandardDocumentBody(minimalDocumentFields({ informacionReferencia: [] })); + expect(result).not.toHaveProperty("InformacionReferencia"); + }); + + it("should include InformacionReferencia when present", () => { + const result = buildStandardDocumentBody( + minimalDocumentFields({ + informacionReferencia: [ + { + tipoDoc: "01" as never, + numero: "clave-ref", + fechaEmision: "2025-01-01T00:00:00-06:00", + codigo: "01" as never, + razon: "Correccion", + }, + ], + }), + ); + expect(result).toHaveProperty("InformacionReferencia"); + const refs = result.InformacionReferencia as Record[]; + expect(refs[0].TipoDoc).toBe("01"); + }); + + it("should not include Otros when absent", () => { + const result = buildStandardDocumentBody(minimalDocumentFields()); + expect(result).not.toHaveProperty("Otros"); + }); + + it("should not include Otros when empty array", () => { + const result = buildStandardDocumentBody(minimalDocumentFields({ otros: [] })); + expect(result).not.toHaveProperty("Otros"); + }); + + it("should include Otros when present", () => { + const result = buildStandardDocumentBody( + minimalDocumentFields({ otros: [{ contenido: "data" }] }), + ); + expect(result).toHaveProperty("Otros"); + const otros = result.Otros as Record; + const items = otros.OtroContenido as Record[]; + expect(items[0]["#text"]).toBe("data"); + }); + + it("should include all optional fields together", () => { + const result = buildStandardDocumentBody( + minimalDocumentFields({ + receptor: minimalReceptor(), + plazoCredito: "90", + otrosCargos: [{ tipoDocumento: "01", detalle: "Cargo", montoOtroCargo: 100 }], + informacionReferencia: [ + { + tipoDoc: "01" as never, + numero: "ref-1", + fechaEmision: "2025-01-01T00:00:00-06:00", + codigo: "04" as never, + razon: "Referencia", + }, + ], + otros: [{ contenido: "extra" }], + }), + ); + expect(result).toHaveProperty("Receptor"); + expect(result.PlazoCredito).toBe("90"); + expect(result).toHaveProperty("OtrosCargos"); + expect(result).toHaveProperty("InformacionReferencia"); + expect(result).toHaveProperty("Otros"); + }); +}); diff --git a/packages/sdk/src/signing/p12-loader.spec.ts b/packages/sdk/src/signing/p12-loader.spec.ts new file mode 100644 index 0000000..b32fa41 --- /dev/null +++ b/packages/sdk/src/signing/p12-loader.spec.ts @@ -0,0 +1,81 @@ +/** + * Tests for PKCS#12 (.p12) loader error paths. + */ + +import { describe, it, expect, beforeAll } from "vitest"; +import { execSync } from "node:child_process"; +import { readFileSync, mkdtempSync, rmSync } from "node:fs"; +import { join } from "node:path"; +import { tmpdir } from "node:os"; + +import { loadP12 } from "./p12-loader.js"; +import { SigningError } from "../errors.js"; + +// --------------------------------------------------------------------------- +// Fixtures +// --------------------------------------------------------------------------- + +const TEST_P12_PIN = "test1234"; + +let p12Buffer: Buffer | undefined; + +beforeAll(() => { + const tempDir = mkdtempSync(join(tmpdir(), "hacienda-p12-test-")); + const keyPath = join(tempDir, "test.key"); + const certPath = join(tempDir, "test.crt"); + const p12Path = join(tempDir, "test.p12"); + + try { + execSync(`openssl genrsa -out "${keyPath}" 2048`, { stdio: "pipe" }); + execSync( + `openssl req -new -x509 -key "${keyPath}" -out "${certPath}" -days 365 -subj "/CN=Test/O=TestOrg/C=CR"`, + { stdio: "pipe" }, + ); + try { + execSync( + `openssl pkcs12 -export -out "${p12Path}" -inkey "${keyPath}" -in "${certPath}" -passout pass:${TEST_P12_PIN} -legacy`, + { stdio: "pipe" }, + ); + } catch { + execSync( + `openssl pkcs12 -export -out "${p12Path}" -inkey "${keyPath}" -in "${certPath}" -passout pass:${TEST_P12_PIN}`, + { stdio: "pipe" }, + ); + } + p12Buffer = readFileSync(p12Path); + } catch { + console.warn("openssl not available, wrong-PIN test will be skipped"); + } + + return () => { + try { + rmSync(tempDir, { recursive: true, force: true }); + } catch { + // Ignore cleanup errors + } + }; +}); + +// --------------------------------------------------------------------------- +// Tests +// --------------------------------------------------------------------------- + +describe("loadP12 – error paths", () => { + it("throws SigningError for invalid/corrupt buffer", async () => { + const corruptBuffer = Buffer.from("not a valid p12 file contents"); + + await expect(loadP12(corruptBuffer, "1234")).rejects.toThrow(SigningError); + }); + + it("throws SigningError for wrong PIN", async () => { + if (!p12Buffer) return; + + await expect(loadP12(p12Buffer, "wrong-pin")).rejects.toThrow(SigningError); + }); + + it("throws SigningError for empty buffer", async () => { + const emptyBuffer = Buffer.alloc(0); + + await expect(loadP12(emptyBuffer, "any-pin")).rejects.toThrow(SigningError); + }); +}); diff --git a/packages/sdk/src/tax/calculator.spec.ts b/packages/sdk/src/tax/calculator.spec.ts index 10c5eaa..656a481 100644 --- a/packages/sdk/src/tax/calculator.spec.ts +++ b/packages/sdk/src/tax/calculator.spec.ts @@ -608,3 +608,252 @@ describe("calculateInvoiceSummary", () => { expect(summary.totalComprobante).toBe(124240); }); }); + +// --------------------------------------------------------------------------- +// round5 – IEEE 754 edge cases +// --------------------------------------------------------------------------- + +describe("round5 – IEEE 754 edge cases", () => { + it("should round 0.123456789 to 0.12346", () => { + expect(round5(0.123456789)).toBe(0.12346); + }); + + it("should round 99999.999999 to 100000 (precision test)", () => { + expect(round5(99999.999999)).toBe(100000); + }); + + it("should round -0.123456789 to -0.12346", () => { + expect(round5(-0.123456789)).toBe(-0.12346); + }); + + it("should preserve precision for large number 999999999.12345", () => { + expect(round5(999999999.12345)).toBe(999999999.12345); + }); + + it("should round 0.000001 to 0 (below half-unit at 5th decimal)", () => { + expect(round5(0.000001)).toBe(0); + }); + + it("should round 0.000005 to 0.00001 (rounds half up at boundary)", () => { + expect(round5(0.000005)).toBe(0.00001); + }); +}); + +// --------------------------------------------------------------------------- +// calculateLineItemTotals – edge cases +// --------------------------------------------------------------------------- + +describe("calculateLineItemTotals – edge cases", () => { + it("should handle micro quantity (cantidad=0.001)", () => { + const item: LineItemInput = { + numeroLinea: 1, + codigoCabys: "4321000000000", + cantidad: 0.001, + unidadMedida: "kg", + detalle: "Micro quantity", + precioUnitario: 10000, + impuesto: [{ codigo: "01", codigoTarifa: "08", tarifa: 13 }], + }; + const result = calculateLineItemTotals(item); + // montoTotal = 0.001 * 10000 = 10 + expect(result.montoTotal).toBe(10); + // tax = 10 * 0.13 = 1.3 + expect(result.impuestoNeto).toBe(1.3); + expect(result.montoTotalLinea).toBe(11.3); + }); + + it("should handle free item (precioUnitario=0)", () => { + const item: LineItemInput = { + numeroLinea: 1, + codigoCabys: "4321000000000", + cantidad: 5, + unidadMedida: "Unid", + detalle: "Free promotional item", + precioUnitario: 0, + impuesto: [{ codigo: "01", codigoTarifa: "08", tarifa: 13 }], + }; + const result = calculateLineItemTotals(item); + expect(result.montoTotal).toBe(0); + expect(result.subTotal).toBe(0); + expect(result.impuestoNeto).toBe(0); + expect(result.montoTotalLinea).toBe(0); + }); + + it("should handle very large amounts (999999.99 * 100)", () => { + const item: LineItemInput = { + numeroLinea: 1, + codigoCabys: "4321000000000", + cantidad: 100, + unidadMedida: "Unid", + detalle: "Expensive bulk", + precioUnitario: 999999.99, + impuesto: [{ codigo: "01", codigoTarifa: "08", tarifa: 13 }], + }; + const result = calculateLineItemTotals(item); + // montoTotal = 100 * 999999.99 = 99999999 + expect(result.montoTotal).toBe(99999999); + const tax = getFirstTax(result); + // tax = 99999999 * 0.13 = 12999999.87 + expect(tax.monto).toBe(12999999.87); + expect(result.montoTotalLinea).toBe(112999998.87); + }); + + it("should handle multiple taxes on same line (IVA + selectivo consumo)", () => { + const item: LineItemInput = { + numeroLinea: 1, + codigoCabys: "4321000000000", + cantidad: 1, + unidadMedida: "Unid", + detalle: "Multi-tax item", + precioUnitario: 10000, + impuesto: [ + { codigo: "01", codigoTarifa: "08", tarifa: 13 }, + { codigo: "02", tarifa: 10 }, + ], + }; + const result = calculateLineItemTotals(item); + expect(result.impuesto).toBeDefined(); + expect(result.impuesto).toHaveLength(2); + // IVA: 10000 * 0.13 = 1300 + expect(result.impuesto?.[0]?.monto).toBe(1300); + // Selectivo: 10000 * 0.10 = 1000 + expect(result.impuesto?.[1]?.monto).toBe(1000); + // impuestoNeto only counts IVA (code "01"), not selectivo consumo ("02") + expect(result.impuestoNeto).toBe(1300); + // montoTotalLinea = subTotal + impuestoNeto (only IVA net) + expect(result.montoTotalLinea).toBe(11300); + }); + + it("should handle 100% exoneration", () => { + const item: LineItemInput = { + numeroLinea: 1, + codigoCabys: "4321000000000", + cantidad: 1, + unidadMedida: "Sp", + detalle: "Fully exonerated", + precioUnitario: 50000, + impuesto: [ + { + codigo: "01", + codigoTarifa: "08", + tarifa: 13, + exoneracion: { + tipoDocumento: "03", + numeroDocumento: "EX-100-2025", + nombreInstitucion: "Ministerio", + fechaEmision: "2025-06-01T00:00:00-06:00", + porcentajeExoneracion: 100, + }, + }, + ], + }; + const result = calculateLineItemTotals(item); + const tax = getFirstTax(result); + expect(tax.monto).toBe(6500); // full tax still recorded + expect(tax.exoneracion?.montoExoneracion).toBe(6500); + expect(result.impuestoNeto).toBe(0); + expect(result.montoTotalLinea).toBe(50000); + }); +}); + +// --------------------------------------------------------------------------- +// calculateInvoiceSummary – edge cases +// --------------------------------------------------------------------------- + +describe("calculateInvoiceSummary – edge cases", () => { + it("should handle invoice summary with all zeros", () => { + const items = [ + calculateLineItemTotals({ + numeroLinea: 1, + codigoCabys: "4321000000000", + cantidad: 1, + unidadMedida: "Unid", + detalle: "Free item", + precioUnitario: 0, + }), + ]; + const summary = calculateInvoiceSummary(items); + expect(summary.totalServGravados).toBe(0); + expect(summary.totalServExentos).toBe(0); + expect(summary.totalServExonerado).toBe(0); + expect(summary.totalMercanciasGravadas).toBe(0); + expect(summary.totalMercanciasExentas).toBe(0); + expect(summary.totalMercExonerada).toBe(0); + expect(summary.totalGravado).toBe(0); + expect(summary.totalExento).toBe(0); + expect(summary.totalExonerado).toBe(0); + expect(summary.totalVenta).toBe(0); + expect(summary.totalDescuentos).toBe(0); + expect(summary.totalVentaNeta).toBe(0); + expect(summary.totalImpuesto).toBe(0); + expect(summary.totalComprobante).toBe(0); + }); + + it("should handle summary with only services", () => { + const items = [ + calculateLineItemTotals({ + numeroLinea: 1, + codigoCabys: "4321000000000", + cantidad: 1, + unidadMedida: "Sp", + detalle: "Service A", + precioUnitario: 20000, + esServicio: true, + impuesto: [{ codigo: "01", codigoTarifa: "08", tarifa: 13 }], + }), + calculateLineItemTotals({ + numeroLinea: 2, + codigoCabys: "4321000000000", + cantidad: 1, + unidadMedida: "Sp", + detalle: "Service B", + precioUnitario: 30000, + esServicio: true, + }), + ]; + const summary = calculateInvoiceSummary(items); + expect(summary.totalServGravados).toBe(20000); + expect(summary.totalServExentos).toBe(30000); + expect(summary.totalMercanciasGravadas).toBe(0); + expect(summary.totalMercanciasExentas).toBe(0); + expect(summary.totalGravado).toBe(20000); + expect(summary.totalExento).toBe(30000); + expect(summary.totalVenta).toBe(50000); + expect(summary.totalImpuesto).toBe(2600); + expect(summary.totalComprobante).toBe(52600); + }); + + it("should handle summary with only merchandise", () => { + const items = [ + calculateLineItemTotals({ + numeroLinea: 1, + codigoCabys: "4321000000000", + cantidad: 3, + unidadMedida: "Unid", + detalle: "Product A", + precioUnitario: 5000, + esServicio: false, + impuesto: [{ codigo: "01", codigoTarifa: "08", tarifa: 13 }], + }), + calculateLineItemTotals({ + numeroLinea: 2, + codigoCabys: "4321000000000", + cantidad: 1, + unidadMedida: "Unid", + detalle: "Product B", + precioUnitario: 8000, + esServicio: false, + }), + ]; + const summary = calculateInvoiceSummary(items); + expect(summary.totalServGravados).toBe(0); + expect(summary.totalServExentos).toBe(0); + expect(summary.totalMercanciasGravadas).toBe(15000); + expect(summary.totalMercanciasExentas).toBe(8000); + expect(summary.totalGravado).toBe(15000); + expect(summary.totalExento).toBe(8000); + expect(summary.totalVenta).toBe(23000); + expect(summary.totalImpuesto).toBe(1950); + expect(summary.totalComprobante).toBe(24950); + }); +});