diff --git a/packages/cli/package.json b/packages/cli/package.json index 579ef9e..bd96de0 100644 --- a/packages/cli/package.json +++ b/packages/cli/package.json @@ -17,8 +17,17 @@ "files": [ "dist" ], + "keywords": [ + "costa-rica", + "hacienda", + "electronic-invoicing", + "factura-electronica", + "comprobantes-electronicos", + "cli" + ], "scripts": { "build": "tsup", + "prepublishOnly": "pnpm run build", "test": "vitest run", "lint": "eslint src/", "typecheck": "tsc --noEmit", diff --git a/packages/cli/src/commands/commands.spec.ts b/packages/cli/src/commands/commands.spec.ts index 56cddd8..82ca308 100644 --- a/packages/cli/src/commands/commands.spec.ts +++ b/packages/cli/src/commands/commands.spec.ts @@ -108,6 +108,14 @@ describe("submit command", () => { expect(args["dry-run"]).toBeDefined(); expect(args.json).toBeDefined(); }); + + it("has profile, p12, and pin args", async () => { + const resolved = await resolveCommand(submitCommand); + const args = resolved.args as Record; + expect(args.profile).toBeDefined(); + expect(args.p12).toBeDefined(); + expect(args.pin).toBeDefined(); + }); }); // --------------------------------------------------------------------------- @@ -128,6 +136,12 @@ describe("status command", () => { expect(args.clave.type).toBe("positional"); expect(args.clave.required).toBe(true); }); + + it("has profile arg", async () => { + const resolved = await resolveCommand(statusCommand); + const args = resolved.args as Record; + expect(args.profile).toBeDefined(); + }); }); // --------------------------------------------------------------------------- @@ -147,6 +161,12 @@ describe("list command", () => { expect(args.limit).toBeDefined(); expect(args.offset).toBeDefined(); }); + + it("has profile arg", async () => { + const resolved = await resolveCommand(listCommand); + const args = resolved.args as Record; + expect(args.profile).toBeDefined(); + }); }); // --------------------------------------------------------------------------- @@ -167,6 +187,12 @@ describe("get command", () => { expect(args.clave.type).toBe("positional"); expect(args.clave.required).toBe(true); }); + + it("has profile arg", async () => { + const resolved = await resolveCommand(getCommand); + const args = resolved.args as Record; + expect(args.profile).toBeDefined(); + }); }); // --------------------------------------------------------------------------- diff --git a/packages/cli/src/commands/get.ts b/packages/cli/src/commands/get.ts index 1b63fc0..f6efff0 100644 --- a/packages/cli/src/commands/get.ts +++ b/packages/cli/src/commands/get.ts @@ -2,14 +2,14 @@ * `hacienda get` command. * * Gets full details of a document by its clave. - * Currently stubbed — requires authenticated API client. * * @module commands/get */ import { defineCommand } from "citty"; -import { parseClave } from "@hacienda-cr/sdk"; -import { warn, error, detail, info, outputJson } from "../utils/format.js"; +import { parseClave, getComprobante } from "@hacienda-cr/sdk"; +import { error, detail, info, outputJson, colorStatus } from "../utils/format.js"; +import { createAuthenticatedClient } from "../utils/api-client.js"; export const getCommand = defineCommand({ meta: { @@ -22,6 +22,11 @@ export const getCommand = defineCommand({ description: "50-digit clave numerica", required: true, }, + profile: { + type: "string", + description: "Config profile name", + default: "default", + }, json: { type: "boolean", description: "Output as JSON", @@ -39,15 +44,21 @@ export const getCommand = defineCommand({ return; } - // Parse the clave to show available info + // Parse the clave for supplementary info const parsed = parseClave(clave); - // API document retrieval requires authentication — show parsed clave - warn("Document retrieval requires authentication. Run `hacienda auth login` first."); + // Authenticate and fetch document + const { httpClient } = await createAuthenticatedClient(args.profile as string); + const doc = await getComprobante(httpClient, clave); if (args.json) { outputJson({ - clave, + clave: doc.clave, + fechaEmision: doc.fechaEmision, + estado: doc.estado, + emisor: doc.emisor, + receptor: doc.receptor, + fechaRespuesta: doc.fechaRespuesta, parsed: { countryCode: parsed.countryCode, date: parsed.date.toISOString(), @@ -57,18 +68,23 @@ export const getCommand = defineCommand({ situation: parsed.situation, securityCode: parsed.securityCode, }, - message: "API document retrieval not yet implemented", }); } else { - info(`Document: ${clave}`); - detail("Country Code", parsed.countryCode); - detail("Date", parsed.date.toISOString().slice(0, 10)); - detail("Taxpayer ID", parsed.taxpayerId); + info(`Document: ${doc.clave}`); + console.log(` Status: ${colorStatus(doc.estado)}`); + detail("Emission Date", doc.fechaEmision); + detail("Emisor", `${doc.emisor.tipoIdentificacion}: ${doc.emisor.numeroIdentificacion}`); + if (doc.receptor) { + detail( + "Receptor", + `${doc.receptor.tipoIdentificacion}: ${doc.receptor.numeroIdentificacion}`, + ); + } + if (doc.fechaRespuesta) detail("Response Date", doc.fechaRespuesta); + console.log(""); detail("Document Type", parsed.documentType); detail("Sequence", String(parsed.sequence)); detail("Situation", parsed.situation); - detail("Security Code", parsed.securityCode); - console.log("\nFull document details will be available after authentication."); } } catch (err) { const message = err instanceof Error ? err.message : "Unknown error occurred"; diff --git a/packages/cli/src/commands/list.ts b/packages/cli/src/commands/list.ts index 46849da..10ab8e1 100644 --- a/packages/cli/src/commands/list.ts +++ b/packages/cli/src/commands/list.ts @@ -2,14 +2,15 @@ * `hacienda list` command. * * Lists recent comprobantes from the Hacienda API. - * Currently stubbed — requires authenticated API client. * * @module commands/list */ import { defineCommand } from "citty"; -import { warn, outputJson, formatTable, colorStatus } from "../utils/format.js"; +import { listComprobantes } from "@hacienda-cr/sdk"; +import { error, outputJson, formatTable, colorStatus } from "../utils/format.js"; import type { TableColumn } from "../utils/format.js"; +import { createAuthenticatedClient } from "../utils/api-client.js"; /** Column definitions for the comprobantes table. */ const COMPROBANTE_COLUMNS: TableColumn[] = [ @@ -21,7 +22,6 @@ const COMPROBANTE_COLUMNS: TableColumn[] = [ minWidth: 12, format: (v) => colorStatus(String(v)), }, - { header: "TYPE", key: "type", minWidth: 10 }, ]; export const listCommand = defineCommand({ @@ -40,6 +40,11 @@ export const listCommand = defineCommand({ description: "Pagination offset", default: "0", }, + profile: { + type: "string", + description: "Config profile name", + default: "default", + }, json: { type: "boolean", description: "Output as JSON", @@ -47,42 +52,58 @@ export const listCommand = defineCommand({ }, }, async run({ args }) { - // API listing requires authentication — show guidance - warn( - "Authenticated API listing requires a configured profile. Run `hacienda auth login` first.", - ); + try { + const limit = Number(args.limit); + const offset = Number(args.offset); - // Show example of what the output will look like - const exampleData = [ - { - clave: "50601012400310123456700100001010000000001199999999", - fechaEmision: "2024-01-12", - estado: "aceptado", - type: "FE", - }, - { - clave: "50601012400310123456700100001010000000002199999998", - fechaEmision: "2024-01-12", - estado: "procesando", - type: "FE", - }, - ]; + if (!Number.isInteger(limit) || limit < 1 || limit > 100) { + error("Invalid --limit. Must be an integer between 1 and 100."); + process.exitCode = 1; + return; + } + if (!Number.isInteger(offset) || offset < 0) { + error("Invalid --offset. Must be a non-negative integer."); + process.exitCode = 1; + return; + } - if (args.json) { - outputJson({ - success: false, - error: "API listing not yet implemented", - exampleFormat: { - totalRegistros: 0, - offset: Number(args.offset), - limit: Number(args.limit), - comprobantes: [], - }, + const { httpClient } = await createAuthenticatedClient(args.profile as string); + + const result = await listComprobantes(httpClient, { + offset, + limit, }); - } else { - console.log("\nExample output format:\n"); - console.log(formatTable(COMPROBANTE_COLUMNS, exampleData)); - console.log("\n(This is placeholder data. Run `hacienda auth login` to connect.)"); + + if (args.json) { + outputJson({ + success: true, + totalRegistros: result.totalRegistros, + offset: result.offset, + comprobantes: result.comprobantes, + }); + } else { + if (result.comprobantes.length === 0) { + console.log("No comprobantes found."); + } else { + console.log( + `\nShowing ${result.comprobantes.length} of ${result.totalRegistros} comprobantes:\n`, + ); + console.log( + formatTable( + COMPROBANTE_COLUMNS, + result.comprobantes as unknown as Record[], + ), + ); + } + } + } catch (err) { + const message = err instanceof Error ? err.message : "Unknown error occurred"; + if (args.json) { + outputJson({ success: false, error: message }); + } else { + error(`List failed: ${message}`); + } + process.exitCode = 1; } }, }); diff --git a/packages/cli/src/commands/status.ts b/packages/cli/src/commands/status.ts index 8fd20ef..3184149 100644 --- a/packages/cli/src/commands/status.ts +++ b/packages/cli/src/commands/status.ts @@ -7,8 +7,9 @@ */ import { defineCommand } from "citty"; -import { parseClave } from "@hacienda-cr/sdk"; -import { warn, error, detail, info, outputJson, colorStatus } from "../utils/format.js"; +import { parseClave, getStatus, extractRejectionReason } from "@hacienda-cr/sdk"; +import { error, detail, info, outputJson, colorStatus } from "../utils/format.js"; +import { createAuthenticatedClient } from "../utils/api-client.js"; export const statusCommand = defineCommand({ meta: { @@ -21,6 +22,11 @@ export const statusCommand = defineCommand({ description: "50-digit clave numerica", required: true, }, + profile: { + type: "string", + description: "Config profile name", + default: "default", + }, json: { type: "boolean", description: "Output as JSON", @@ -41,12 +47,22 @@ export const statusCommand = defineCommand({ // Parse the clave to show its components const parsed = parseClave(clave); - // API polling requires authentication — show parsed clave - warn("Status polling requires authentication. Run `hacienda auth login` first."); + // Authenticate and query status + const { httpClient } = await createAuthenticatedClient(args.profile as string); + const status = await getStatus(httpClient, clave); + + // Extract rejection reason if available + let rejectionReason: string | undefined; + if (status.responseXml) { + rejectionReason = extractRejectionReason(status.responseXml); + } if (args.json) { outputJson({ clave, + status: status.status, + date: status.date, + rejectionReason, parsed: { countryCode: parsed.countryCode, date: parsed.date.toISOString(), @@ -56,19 +72,18 @@ export const statusCommand = defineCommand({ situation: parsed.situation, securityCode: parsed.securityCode, }, - status: "unknown", - message: "API status polling not yet implemented", }); } else { info(`Clave: ${clave}`); + console.log(` Status: ${colorStatus(status.status)}`); + if (status.date) detail("Date", status.date); + if (rejectionReason) detail("Reason", rejectionReason); + console.log(""); detail("Country Code", parsed.countryCode); - detail("Date", parsed.date.toISOString().slice(0, 10)); detail("Taxpayer ID", parsed.taxpayerId); detail("Document Type", parsed.documentType); detail("Sequence", String(parsed.sequence)); detail("Situation", parsed.situation); - detail("Security Code", parsed.securityCode); - console.log(`\n Status: ${colorStatus("unknown")} (requires authentication)`); } } catch (err) { const message = err instanceof Error ? err.message : "Unknown error occurred"; diff --git a/packages/cli/src/commands/submit.ts b/packages/cli/src/commands/submit.ts index 623fbad..3403a5d 100644 --- a/packages/cli/src/commands/submit.ts +++ b/packages/cli/src/commands/submit.ts @@ -1,8 +1,8 @@ /** * `hacienda submit` command. * - * Reads a JSON invoice file, validates it, builds XML, and submits to Hacienda. - * Signing is stubbed until the signing module is complete. + * Reads a JSON invoice file, validates it, builds XML, signs it, + * and submits to the Hacienda API. * * @module commands/submit */ @@ -12,8 +12,15 @@ import { resolve } from "node:path"; import { defineCommand } from "citty"; import { FacturaElectronicaSchema } from "@hacienda-cr/shared"; import type { FacturaElectronica } from "@hacienda-cr/shared"; -import { buildFacturaXml, validateFacturaInput } from "@hacienda-cr/sdk"; -import { success, error, detail, warn, outputJson } from "../utils/format.js"; +import type { SubmissionRequest } from "@hacienda-cr/shared"; +import { + buildFacturaXml, + validateFacturaInput, + signAndEncode, + submitAndWait, +} from "@hacienda-cr/sdk"; +import { success, error, detail, info, outputJson } from "../utils/format.js"; +import { createAuthenticatedClient } from "../utils/api-client.js"; export const submitCommand = defineCommand({ meta: { @@ -31,6 +38,20 @@ export const submitCommand = defineCommand({ description: "Validate and build XML without submitting", default: false, }, + profile: { + type: "string", + description: "Config profile name", + default: "default", + }, + p12: { + type: "string", + description: "Path to .p12 certificate file (overrides profile)", + }, + pin: { + type: "string", + description: + "PIN for the .p12 certificate (prefer HACIENDA_P12_PIN env var — CLI args are visible in process lists)", + }, json: { type: "boolean", description: "Output as JSON", @@ -124,25 +145,101 @@ export const submitCommand = defineCommand({ return; } - // Submission requires signing and API client — stub for now - warn( - "Signing and API submission are not yet implemented. Use --dry-run to validate and preview XML.", - ); + // Authenticate + const { httpClient, config } = await createAuthenticatedClient(args.profile as string); + + // Resolve .p12 path and PIN + const p12Path = + (args.p12 as string | undefined) ?? + process.env["HACIENDA_P12_PATH"] ?? + config.profile.p12_path; + if (!p12Path) { + error( + "Missing .p12 certificate path. Use --p12, set HACIENDA_P12_PATH, or configure in profile.", + ); + process.exitCode = 1; + return; + } + + const p12Pin = (args.pin as string | undefined) ?? config.p12Pin; + if (!p12Pin) { + error("Missing .p12 PIN. Use --pin or set HACIENDA_P12_PIN environment variable."); + process.exitCode = 1; + return; + } + + // Read the .p12 file + let p12Buffer: Buffer; + try { + p12Buffer = await readFile(resolve(p12Path)); + } catch (err) { + const detail = err instanceof Error ? err.message : String(err); + error(`Cannot read .p12 file: ${p12Path}: ${detail}`); + process.exitCode = 1; + return; + } + + // Sign and Base64-encode the XML + if (!args.json) { + info("Signing XML..."); + } + const signedXmlBase64 = await signAndEncode(xml, p12Buffer, p12Pin); + + // Build submission request + const invoiceDoc = validation.data; + const receptor = invoiceDoc.receptor?.identificacion + ? { + tipoIdentificacion: invoiceDoc.receptor.identificacion.tipo, + numeroIdentificacion: invoiceDoc.receptor.identificacion.numero, + } + : undefined; + + const request: SubmissionRequest = { + clave: invoiceDoc.clave, + fecha: invoiceDoc.fechaEmision, + emisor: { + tipoIdentificacion: invoiceDoc.emisor.identificacion.tipo, + numeroIdentificacion: invoiceDoc.emisor.identificacion.numero, + }, + receptor, + comprobanteXml: signedXmlBase64, + }; + + // Submit and wait for response + if (!args.json) { + info("Submitting to Hacienda..."); + } + + const result = await submitAndWait(httpClient, request, { + onPoll: (status, attempt) => { + if (!args.json) { + info(`Polling status (attempt ${attempt}): ${status.status}`); + } + }, + }); + if (args.json) { outputJson({ - success: false, - error: "Submission not yet implemented. Use --dry-run to validate.", - clave: validation.data.clave, - xml, + success: result.accepted, + clave: result.clave, + status: result.status, + date: result.date, + rejectionReason: result.rejectionReason, + pollAttempts: result.pollAttempts, }); + } else if (result.accepted) { + success("Document accepted by Hacienda!"); + detail("Clave", result.clave); + detail("Status", result.status); + if (result.date) detail("Date", result.date); } else { - detail("Clave", validation.data.clave); - detail("Consecutivo", validation.data.numeroConsecutivo); - console.log( - "\nOnce signing is implemented, this command will sign the XML and submit it to the Hacienda API.", - ); + error(`Document rejected by Hacienda: ${result.status}`); + detail("Clave", result.clave); + if (result.rejectionReason) { + detail("Reason", result.rejectionReason); + } + process.exitCode = 1; } - process.exitCode = 1; } catch (err) { const message = err instanceof Error ? err.message : "Unknown error occurred"; if (args.json) { diff --git a/packages/cli/src/utils/api-client.ts b/packages/cli/src/utils/api-client.ts new file mode 100644 index 0000000..c2d23cd --- /dev/null +++ b/packages/cli/src/utils/api-client.ts @@ -0,0 +1,23 @@ +/** + * Shared auth helper for CLI commands that need an authenticated API client. + * + * Delegates to the SDK's {@link bootstrapClient} for the full auth flow. + * + * @module utils/api-client + */ + +import { bootstrapClient } from "@hacienda-cr/sdk"; +import type { BootstrapResult } from "@hacienda-cr/sdk"; + +export type { BootstrapResult }; + +/** + * Creates an authenticated HttpClient from a saved config profile. + * + * @param profileName - Profile name (defaults to "default"). + * @returns Authenticated HTTP client and resolved config. + * @throws If the profile is missing, password is not set, or authentication fails. + */ +export async function createAuthenticatedClient(profileName?: string): Promise { + return bootstrapClient({ profileName }); +} diff --git a/packages/cli/src/utils/index.ts b/packages/cli/src/utils/index.ts index 67980c3..ba78904 100644 --- a/packages/cli/src/utils/index.ts +++ b/packages/cli/src/utils/index.ts @@ -20,3 +20,6 @@ export { detail, } from "./format.js"; export type { TableColumn } from "./format.js"; + +export { createAuthenticatedClient } from "./api-client.js"; +export type { BootstrapResult } from "./api-client.js"; diff --git a/packages/mcp/package.json b/packages/mcp/package.json index fe9ddf6..7a196ce 100644 --- a/packages/mcp/package.json +++ b/packages/mcp/package.json @@ -17,8 +17,18 @@ "files": [ "dist" ], + "keywords": [ + "costa-rica", + "hacienda", + "electronic-invoicing", + "factura-electronica", + "comprobantes-electronicos", + "mcp", + "model-context-protocol" + ], "scripts": { "build": "tsup", + "prepublishOnly": "pnpm run build", "test": "vitest run", "lint": "eslint src/", "typecheck": "tsc --noEmit", diff --git a/packages/mcp/src/server.spec.ts b/packages/mcp/src/server.spec.ts index 9396f5c..0ad6b0e 100644 --- a/packages/mcp/src/server.spec.ts +++ b/packages/mcp/src/server.spec.ts @@ -5,12 +5,43 @@ * MCP protocol flow (initialize, list tools, list resources, call tools, read resources). */ -import { describe, it, expect, beforeAll, afterAll } from "vitest"; +import { describe, it, expect, beforeAll, afterAll, vi, beforeEach } from "vitest"; import { Client } from "@modelcontextprotocol/sdk/client/index.js"; import { createServer } from "./server.js"; import { createLinkedTransports } from "./testing/in-memory-transport.js"; +// Use vi.hoisted so mocks are available when vi.mock factories run +const { + mockGetStatus, + mockListComprobantes, + mockGetComprobante, + mockLookupTaxpayer, + mockCreateMcpApiClient, +} = vi.hoisted(() => ({ + mockGetStatus: vi.fn(), + mockListComprobantes: vi.fn(), + mockGetComprobante: vi.fn(), + mockLookupTaxpayer: vi.fn(), + mockCreateMcpApiClient: vi.fn(), +})); + +vi.mock("@hacienda-cr/sdk", async (importOriginal) => { + const actual = (await importOriginal()) as Record; + return { + ...actual, + lookupTaxpayer: mockLookupTaxpayer, + getStatus: mockGetStatus, + listComprobantes: mockListComprobantes, + getComprobante: mockGetComprobante, + }; +}); + +vi.mock("./tools/api-client.js", () => ({ + createMcpApiClient: (...args: unknown[]) => mockCreateMcpApiClient(...args), + clearClientCache: vi.fn(), +})); + /** * Helper: extract text content from the first content block of a tool result. */ @@ -50,6 +81,10 @@ describe("MCP Server", () => { await client.close(); }); + beforeEach(() => { + vi.clearAllMocks(); + }); + // ------------------------------------------------------------------------- // Server initialization // ------------------------------------------------------------------------- @@ -366,19 +401,47 @@ describe("MCP Server", () => { // ------------------------------------------------------------------------- describe("check_status", () => { - it("should return status info for a valid clave", async () => { - // Build a valid 50-digit clave - const clave = "50601012500310123456700100001010000000001100000001"; + it("should return error when no profile is configured", async () => { + mockCreateMcpApiClient.mockRejectedValueOnce( + new Error("No profile configured. Run `hacienda auth login` first."), + ); + const clave = "50601012500310123456700100001010000000001100000001"; const result = await client.callTool({ name: "check_status", arguments: { clave }, }); + expect(result.isError).toBe(true); + const text = getTextContent(result.content as { type: string; text: string }[]); + expect(text).toContain("Error checking status"); + }); + + it("should return status for a valid document", async () => { + const fakeHttpClient = {}; + mockCreateMcpApiClient.mockResolvedValueOnce(fakeHttpClient); + mockGetStatus.mockResolvedValueOnce({ + clave: "50601012500310123456700100001010000000001100000001", + status: "aceptado", + date: "2025-01-12T10:00:00Z", + responseXml: undefined, + raw: {}, + }); + + const result = await client.callTool({ + name: "check_status", + arguments: { clave: "50601012500310123456700100001010000000001100000001" }, + }); + expect(result.isError).toBeFalsy(); const text = getTextContent(result.content as { type: string; text: string }[]); expect(text).toContain("Document Status"); - expect(text).toContain("placeholder"); + expect(text).toContain("Status: aceptado"); + expect(text).toContain("Response Date: 2025-01-12T10:00:00Z"); + expect(mockGetStatus).toHaveBeenCalledWith( + fakeHttpClient, + "50601012500310123456700100001010000000001100000001", + ); }); it("should return error for invalid clave", async () => { @@ -396,17 +459,74 @@ describe("MCP Server", () => { // ------------------------------------------------------------------------- describe("list_documents", () => { - it("should return a placeholder list", async () => { + it("should return error when no profile is configured", async () => { + mockCreateMcpApiClient.mockRejectedValueOnce( + new Error("No profile configured. Run `hacienda auth login` first."), + ); + const result = await client.callTool({ name: "list_documents", arguments: { limit: 5 }, }); + expect(result.isError).toBe(true); + const text = getTextContent(result.content as { type: string; text: string }[]); + expect(text).toContain("Error listing documents"); + }); + + it("should return a list of documents", async () => { + const fakeHttpClient = {}; + mockCreateMcpApiClient.mockResolvedValueOnce(fakeHttpClient); + mockListComprobantes.mockResolvedValueOnce({ + totalRegistros: 2, + offset: 0, + comprobantes: [ + { + clave: "50601012500310123456700100001010000000001100000001", + fechaEmision: "2025-01-12", + estado: "aceptado", + emisor: { tipoIdentificacion: "02", numeroIdentificacion: "3101234567" }, + }, + { + clave: "50601012500310123456700100001010000000002100000002", + fechaEmision: "2025-01-13", + estado: "rechazado", + emisor: { tipoIdentificacion: "02", numeroIdentificacion: "3101234567" }, + }, + ], + }); + + const result = await client.callTool({ + name: "list_documents", + arguments: { limit: 10 }, + }); + expect(result.isError).toBeFalsy(); const text = getTextContent(result.content as { type: string; text: string }[]); expect(text).toContain("Document List"); - expect(text).toContain("Limit: 5"); - expect(text).toContain("placeholder"); + expect(text).toContain("Total: 2 documents"); + expect(text).toContain("aceptado"); + expect(text).toContain("rechazado"); + expect(text).toContain("3101234567"); + }); + + it("should show message when no documents match", async () => { + const fakeHttpClient = {}; + mockCreateMcpApiClient.mockResolvedValueOnce(fakeHttpClient); + mockListComprobantes.mockResolvedValueOnce({ + totalRegistros: 0, + offset: 0, + comprobantes: [], + }); + + const result = await client.callTool({ + name: "list_documents", + arguments: { limit: 10 }, + }); + + expect(result.isError).toBeFalsy(); + const text = getTextContent(result.content as { type: string; text: string }[]); + expect(text).toContain("No documents found"); }); }); @@ -415,18 +535,52 @@ describe("MCP Server", () => { // ------------------------------------------------------------------------- describe("get_document", () => { - it("should return document details for a valid clave", async () => { - const clave = "50601012500310123456700100001010000000001100000001"; + it("should return error when no profile is configured", async () => { + mockCreateMcpApiClient.mockRejectedValueOnce( + new Error("No profile configured. Run `hacienda auth login` first."), + ); + const clave = "50601012500310123456700100001010000000001100000001"; const result = await client.callTool({ name: "get_document", arguments: { clave }, }); + expect(result.isError).toBe(true); + const text = getTextContent(result.content as { type: string; text: string }[]); + expect(text).toContain("Error getting document"); + }); + + it("should return full document details", async () => { + const fakeHttpClient = {}; + mockCreateMcpApiClient.mockResolvedValueOnce(fakeHttpClient); + + const sampleXml = "test"; + const xmlBase64 = Buffer.from(sampleXml).toString("base64"); + + mockGetComprobante.mockResolvedValueOnce({ + clave: "50601012500310123456700100001010000000001100000001", + fechaEmision: "2025-01-12", + estado: "aceptado", + emisor: { tipoIdentificacion: "02", numeroIdentificacion: "3101234567" }, + receptor: { tipoIdentificacion: "01", numeroIdentificacion: "101230456" }, + comprobanteXml: xmlBase64, + fechaRespuesta: "2025-01-12T10:05:00Z", + }); + + const result = await client.callTool({ + name: "get_document", + arguments: { clave: "50601012500310123456700100001010000000001100000001" }, + }); + expect(result.isError).toBeFalsy(); const text = getTextContent(result.content as { type: string; text: string }[]); expect(text).toContain("Document Details"); - expect(text).toContain("placeholder"); + expect(text).toContain("Status: aceptado"); + expect(text).toContain("Emisor: 02 3101234567"); + expect(text).toContain("Receptor: 01 101230456"); + expect(text).toContain("FacturaElectronica"); + expect(text).toContain("Response Date: 2025-01-12T10:05:00Z"); }); }); @@ -435,7 +589,19 @@ describe("MCP Server", () => { // ------------------------------------------------------------------------- describe("lookup_taxpayer", () => { - it("should return taxpayer info placeholder", async () => { + it("should return taxpayer info from the API", async () => { + mockLookupTaxpayer.mockResolvedValueOnce({ + nombre: "EMPRESA DE PRUEBA S.A.", + tipoIdentificacion: "02", + actividades: [ + { + codigo: "620100", + descripcion: "Actividades de programación informática", + estado: "Activo", + }, + ], + }); + const result = await client.callTool({ name: "lookup_taxpayer", arguments: { identificacion: "3101234567" }, @@ -443,9 +609,25 @@ describe("MCP Server", () => { expect(result.isError).toBeFalsy(); const text = getTextContent(result.content as { type: string; text: string }[]); - expect(text).toContain("Taxpayer Lookup"); + expect(text).toContain("Taxpayer Information"); + expect(text).toContain("EMPRESA DE PRUEBA S.A."); expect(text).toContain("3101234567"); - expect(text).toContain("02 - Cedula Juridica"); + expect(text).toContain("620100"); + expect(text).toContain("programación informática"); + }); + + it("should return error when API call fails", async () => { + mockLookupTaxpayer.mockRejectedValueOnce(new Error("Network error")); + + const result = await client.callTool({ + name: "lookup_taxpayer", + arguments: { identificacion: "3101234567" }, + }); + + expect(result.isError).toBe(true); + const text = getTextContent(result.content as { type: string; text: string }[]); + expect(text).toContain("Error looking up taxpayer"); + expect(text).toContain("Network error"); }); }); diff --git a/packages/mcp/src/tools/api-client.ts b/packages/mcp/src/tools/api-client.ts new file mode 100644 index 0000000..8ea2c09 --- /dev/null +++ b/packages/mcp/src/tools/api-client.ts @@ -0,0 +1,43 @@ +/** + * Shared auth helper for MCP tools that need an authenticated API client. + * + * Delegates to the SDK's {@link bootstrapClient} and caches the resulting + * HttpClient per profile name so that repeated tool calls within a session + * reuse the same TokenManager (which handles token refresh internally). + * + * @module tools/api-client + */ + +import { bootstrapClient } from "@hacienda-cr/sdk"; +import type { HttpClient } from "@hacienda-cr/sdk"; + +/** Cached clients keyed by profile name. */ +const clientCache = new Map(); + +/** + * Returns an authenticated HttpClient for the given profile, using a cache + * so that repeated calls reuse the same TokenManager / token. + * + * @param profileName - Profile name (defaults to "default"). + * @returns Authenticated HTTP client. + * @throws Error with user-friendly message if auth fails. + */ +export async function createMcpApiClient(profileName?: string): Promise { + const key = profileName ?? "default"; + + const cached = clientCache.get(key); + if (cached) { + return cached; + } + + const { httpClient } = await bootstrapClient({ profileName: key }); + clientCache.set(key, httpClient); + return httpClient; +} + +/** + * Clears the client cache. Primarily used for testing. + */ +export function clearClientCache(): void { + clientCache.clear(); +} diff --git a/packages/mcp/src/tools/document-tools.ts b/packages/mcp/src/tools/document-tools.ts index 6983825..8e9ab78 100644 --- a/packages/mcp/src/tools/document-tools.ts +++ b/packages/mcp/src/tools/document-tools.ts @@ -1,17 +1,23 @@ /** * MCP tools: check_status, list_documents, get_document * - * These tools provide access to document status checking and retrieval. - * Currently returns placeholder responses as the API client submission - * flow is not yet fully implemented. + * These tools provide access to document status checking and retrieval + * via the Hacienda API. */ import { z } from "zod"; import type { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; -import { parseClave } from "@hacienda-cr/sdk"; +import { + parseClave, + getStatus, + extractRejectionReason, + listComprobantes, + getComprobante, +} from "@hacienda-cr/sdk"; import { DOCUMENT_TYPE_NAMES } from "@hacienda-cr/shared"; import type { DocumentTypeCode } from "@hacienda-cr/shared"; +import { createMcpApiClient } from "./api-client.js"; // --------------------------------------------------------------------------- // check_status @@ -21,9 +27,11 @@ export function registerCheckStatusTool(server: McpServer): void { server.tool( "check_status", "Check the processing status of an electronic document by its 50-digit clave numerica. " + - "Returns the current status from Hacienda (recibido, procesando, aceptado, rechazado).", + "Returns the current status from Hacienda (recibido, procesando, aceptado, rechazado). " + + "Requires a configured profile (run `hacienda auth login` first).", { clave: z.string().length(50).describe("The 50-digit clave numerica of the document to check"), + profile: z.string().default("default").describe('Config profile name (default: "default")'), }, async (args) => { try { @@ -33,13 +41,22 @@ export function registerCheckStatusTool(server: McpServer): void { DOCUMENT_TYPE_NAMES[parsed.documentType as DocumentTypeCode] ?? `Unknown (${parsed.documentType})`; - // Placeholder response — real implementation will call the Hacienda API + // Authenticate and query status + const httpClient = await createMcpApiClient(args.profile); + const status = await getStatus(httpClient, args.clave); + + // Extract rejection reason if available + let rejectionReason: string | undefined; + if (status.responseXml) { + rejectionReason = extractRejectionReason(status.responseXml); + } + return { content: [ { type: "text" as const, text: [ - `Document Status (placeholder — API client not yet connected)`, + `Document Status`, ``, `Clave: ${args.clave}`, `Document Type: ${docTypeName}`, @@ -47,12 +64,12 @@ export function registerCheckStatusTool(server: McpServer): void { `Date: ${parsed.dateRaw}`, `Sequence: ${parsed.sequence}`, ``, - `Status: pending (placeholder)`, - ``, - `Note: This is a placeholder response. Once the API client ` + - `is fully connected, this tool will query the Hacienda ` + - `API for the real-time status.`, - ].join("\n"), + `Status: ${status.status}`, + status.date ? `Response Date: ${status.date}` : null, + rejectionReason ? `Rejection Reason: ${rejectionReason}` : null, + ] + .filter(Boolean) + .join("\n"), }, ], }; @@ -81,7 +98,8 @@ export function registerListDocumentsTool(server: McpServer): void { "list_documents", "List recent electronic documents. " + "Optionally filter by date range, emisor, or receptor. " + - "Returns a summary list with clave, date, type, and status.", + "Returns a summary list with clave, date, type, and status. " + + "Requires a configured profile (run `hacienda auth login` first).", { limit: z .number() @@ -101,35 +119,62 @@ export function registerListDocumentsTool(server: McpServer): void { .describe("Filter by receiver identification number"), fechaDesde: z.string().optional().describe("Filter by start date (ISO 8601)"), fechaHasta: z.string().optional().describe("Filter by end date (ISO 8601)"), + profile: z.string().default("default").describe('Config profile name (default: "default")'), }, async (args) => { - // Placeholder response — real implementation will call the Hacienda API - return { - content: [ - { - type: "text" as const, - text: [ - `Document List (placeholder — API client not yet connected)`, - ``, - `Filters applied:`, - ` Limit: ${args.limit}`, - ` Offset: ${args.offset}`, - args.emisorIdentificacion ? ` Emisor: ${args.emisorIdentificacion}` : null, - args.receptorIdentificacion ? ` Receptor: ${args.receptorIdentificacion}` : null, - args.fechaDesde ? ` From: ${args.fechaDesde}` : null, - args.fechaHasta ? ` Until: ${args.fechaHasta}` : null, - ``, - `Results: 0 documents`, + try { + const httpClient = await createMcpApiClient(args.profile); + + const result = await listComprobantes(httpClient, { + offset: args.offset, + limit: args.limit, + emisorIdentificacion: args.emisorIdentificacion, + receptorIdentificacion: args.receptorIdentificacion, + fechaEmisionDesde: args.fechaDesde, + fechaEmisionHasta: args.fechaHasta, + }); + + const lines = [ + `Document List`, + ``, + `Total: ${result.totalRegistros} documents`, + `Showing: ${result.comprobantes.length} (offset ${result.offset})`, + ``, + ]; + + if (result.comprobantes.length === 0) { + lines.push("No documents found matching the criteria."); + } else { + for (const doc of result.comprobantes) { + lines.push( + `- ${doc.clave}`, + ` Date: ${doc.fechaEmision} | Status: ${doc.estado}`, + ` Emisor: ${doc.emisor.numeroIdentificacion}`, ``, - `Note: This is a placeholder response. Once the API client ` + - `is fully connected, this tool will query the Hacienda ` + - `API for actual documents.`, - ] - .filter(Boolean) - .join("\n"), - }, - ], - }; + ); + } + } + + return { + content: [ + { + type: "text" as const, + text: lines.join("\n"), + }, + ], + }; + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + return { + content: [ + { + type: "text" as const, + text: `Error listing documents: ${message}`, + }, + ], + isError: true, + }; + } }, ); } @@ -142,12 +187,14 @@ export function registerGetDocumentTool(server: McpServer): void { server.tool( "get_document", "Get full details of an electronic document by its 50-digit clave numerica. " + - "Returns the document metadata, status, and XML content.", + "Returns the document metadata, status, and XML content. " + + "Requires a configured profile (run `hacienda auth login` first).", { clave: z .string() .length(50) .describe("The 50-digit clave numerica of the document to retrieve"), + profile: z.string().default("default").describe('Config profile name (default: "default")'), }, async (args) => { try { @@ -157,31 +204,48 @@ export function registerGetDocumentTool(server: McpServer): void { DOCUMENT_TYPE_NAMES[parsed.documentType as DocumentTypeCode] ?? `Unknown (${parsed.documentType})`; - // Placeholder response + // Authenticate and fetch document + const httpClient = await createMcpApiClient(args.profile); + const doc = await getComprobante(httpClient, args.clave); + + // Decode the submitted XML if available + let xmlContent: string | undefined; + if (doc.comprobanteXml) { + try { + xmlContent = Buffer.from(doc.comprobanteXml, "base64").toString("utf-8"); + } catch { + xmlContent = "(unable to decode XML)"; + } + } + return { content: [ { type: "text" as const, text: [ - `Document Details (placeholder — API client not yet connected)`, + `Document Details`, ``, - `Clave: ${args.clave}`, + `Clave: ${doc.clave}`, `Document Type: ${docTypeName}`, `Taxpayer ID: ${parsed.taxpayerId}`, - `Date: ${parsed.dateRaw}`, + `Emission Date: ${doc.fechaEmision}`, + `Status: ${doc.estado}`, + doc.fechaRespuesta ? `Response Date: ${doc.fechaRespuesta}` : null, + ``, + `Emisor: ${doc.emisor.tipoIdentificacion} ${doc.emisor.numeroIdentificacion}`, + doc.receptor + ? `Receptor: ${doc.receptor.tipoIdentificacion} ${doc.receptor.numeroIdentificacion}` + : null, + ``, `Branch: ${parsed.branch}`, `POS: ${parsed.pos}`, `Sequence: ${parsed.sequence}`, `Situation: ${parsed.situation}`, `Security Code: ${parsed.securityCode}`, - ``, - `Status: pending (placeholder)`, - `XML: not available (placeholder)`, - ``, - `Note: This is a placeholder response. Once the API client ` + - `is fully connected, this tool will retrieve the full ` + - `document from the Hacienda API.`, - ].join("\n"), + xmlContent ? `\n--- XML ---\n${xmlContent}` : null, + ] + .filter(Boolean) + .join("\n"), }, ], }; diff --git a/packages/mcp/src/tools/lookup-tools.ts b/packages/mcp/src/tools/lookup-tools.ts index 0c90206..7c51bd0 100644 --- a/packages/mcp/src/tools/lookup-tools.ts +++ b/packages/mcp/src/tools/lookup-tools.ts @@ -1,13 +1,14 @@ /** * MCP tools: lookup_taxpayer, draft_invoice * - * - lookup_taxpayer: look up taxpayer info by cedula (placeholder) + * - lookup_taxpayer: look up taxpayer info by cedula (public API, no auth needed) * - draft_invoice: help draft an invoice interactively with sensible defaults */ import { z } from "zod"; import type { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; +import { lookupTaxpayer } from "@hacienda-cr/sdk"; import { IDENTIFICATION_TYPE_NAMES } from "@hacienda-cr/shared"; import type { IdentificationType } from "@hacienda-cr/shared"; @@ -29,46 +30,43 @@ export function registerLookupTaxpayerTool(server: McpServer): void { .describe("Taxpayer identification number (9-12 digits)"), }, async (args) => { - // Determine probable ID type based on length - let probableType: string; - switch (args.identificacion.length) { - case 9: - probableType = "01 - Cedula Fisica"; - break; - case 10: - probableType = "02 - Cedula Juridica / 04 - NITE"; - break; - case 11: - case 12: - probableType = "03 - DIMEX"; - break; - default: - probableType = "Unknown"; - } + try { + const info = await lookupTaxpayer(args.identificacion); - // Placeholder response — will call Hacienda economic activity API - return { - content: [ - { - type: "text" as const, - text: [ - `Taxpayer Lookup (placeholder — API not yet connected)`, - ``, - `Identification: ${args.identificacion}`, - `Probable Type: ${probableType}`, - ``, - `Available identification types:`, - ...Object.entries(IDENTIFICATION_TYPE_NAMES).map( - ([code, name]) => ` ${code}: ${name}`, - ), - ``, - `Note: This is a placeholder response. Once the economic ` + - `activity API is connected, this tool will return the ` + - `taxpayer's name and registered activities.`, - ].join("\n"), - }, - ], - }; + const activities = + info.actividades.length > 0 + ? info.actividades.map((a) => ` - [${a.codigo}] ${a.descripcion} (${a.estado})`) + : [" (no activities registered)"]; + + return { + content: [ + { + type: "text" as const, + text: [ + `Taxpayer Information`, + ``, + `Name: ${info.nombre}`, + `Identification: ${args.identificacion}`, + `Type: ${info.tipoIdentificacion}`, + ``, + `Economic Activities:`, + ...activities, + ].join("\n"), + }, + ], + }; + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + return { + content: [ + { + type: "text" as const, + text: `Error looking up taxpayer: ${message}`, + }, + ], + isError: true, + }; + } }, ); } diff --git a/packages/sdk/package.json b/packages/sdk/package.json index 65e5419..54f52a1 100644 --- a/packages/sdk/package.json +++ b/packages/sdk/package.json @@ -14,8 +14,16 @@ "files": [ "dist" ], + "keywords": [ + "costa-rica", + "hacienda", + "electronic-invoicing", + "factura-electronica", + "comprobantes-electronicos" + ], "scripts": { "build": "tsup", + "prepublishOnly": "pnpm run build", "test": "vitest run", "lint": "eslint src/", "typecheck": "tsc --noEmit", diff --git a/packages/sdk/src/bootstrap.ts b/packages/sdk/src/bootstrap.ts new file mode 100644 index 0000000..d5935c7 --- /dev/null +++ b/packages/sdk/src/bootstrap.ts @@ -0,0 +1,81 @@ +/** + * Bootstrap helper — creates an authenticated HttpClient from a saved config profile. + * + * Used by both the CLI and MCP packages to avoid duplicating the + * config → credentials → TokenManager → HttpClient auth flow. + * + * @module bootstrap + */ + +import { z } from "zod"; + +import { loadConfig } from "./config/config-manager.js"; +import type { ConfigManagerOptions } from "./config/config-manager.js"; +import type { ResolvedConfig } from "./config/types.js"; +import { loadCredentials } from "./auth/credentials.js"; +import { getEnvironmentConfig } from "./auth/environment.js"; +import { TokenManager } from "./auth/token-manager.js"; +import { Environment, IdType } from "./auth/types.js"; +import { HttpClient } from "./api/http-client.js"; + +/** Result of bootstrapping an authenticated client. */ +export interface BootstrapResult { + /** Ready-to-use authenticated HTTP client. */ + readonly httpClient: HttpClient; + /** The resolved config (profile + env vars). */ + readonly config: ResolvedConfig; +} + +/** Options for the bootstrap helper. */ +export interface BootstrapOptions { + /** Profile name (defaults to "default"). */ + profileName?: string; + /** Override config dir and env vars (mainly for testing). */ + configOptions?: ConfigManagerOptions; +} + +// Zod schemas for runtime validation of config values +const IdTypeSchema = z.nativeEnum(IdType, { + message: 'Invalid cedula type in profile. Expected "01", "02", "03", or "04".', +}); + +const EnvironmentEnumSchema = z.nativeEnum(Environment, { + message: 'Invalid environment in profile. Expected "sandbox" or "production".', +}); + +/** + * Creates an authenticated HttpClient from a saved config profile. + * + * Loads the profile, validates types at runtime, authenticates via + * TokenManager, and returns a ready-to-use HttpClient. + * + * @param options - Bootstrap configuration. + * @returns The authenticated HTTP client and resolved config. + * @throws If the profile is missing, password is not set, types are invalid, + * or authentication fails. + */ +export async function bootstrapClient(options: BootstrapOptions = {}): Promise { + const config = await loadConfig(options.profileName ?? "default", options.configOptions); + + if (!config.password) { + throw new Error("Missing password. Set the HACIENDA_PASSWORD environment variable."); + } + + // Runtime-validate the cedula type and environment from the config + const idType = IdTypeSchema.parse(config.profile.cedula_type); + const environment = EnvironmentEnumSchema.parse(config.profile.environment); + + const credentials = loadCredentials({ + idType, + idNumber: config.profile.cedula, + password: config.password, + }); + + const envConfig = getEnvironmentConfig(environment); + const tokenManager = new TokenManager({ envConfig }); + await tokenManager.authenticate(credentials); + + const httpClient = new HttpClient({ envConfig, tokenManager }); + + return { httpClient, config }; +} diff --git a/packages/sdk/src/index.ts b/packages/sdk/src/index.ts index d6dd774..6e75bac 100644 --- a/packages/sdk/src/index.ts +++ b/packages/sdk/src/index.ts @@ -14,6 +14,13 @@ export const PACKAGE_NAME = "@hacienda-cr/sdk" as const; export { HaciendaClient, HaciendaClientOptionsSchema } from "./client.js"; export type { HaciendaClientOptions } from "./client.js"; +// --------------------------------------------------------------------------- +// Bootstrap — convenience auth helper for CLI / MCP consumers +// --------------------------------------------------------------------------- + +export { bootstrapClient } from "./bootstrap.js"; +export type { BootstrapResult, BootstrapOptions } from "./bootstrap.js"; + // --------------------------------------------------------------------------- // SDK error hierarchy // --------------------------------------------------------------------------- diff --git a/shared/package.json b/shared/package.json index 6ebdf56..ddc2edf 100644 --- a/shared/package.json +++ b/shared/package.json @@ -14,8 +14,16 @@ "files": [ "dist" ], + "keywords": [ + "costa-rica", + "hacienda", + "electronic-invoicing", + "factura-electronica", + "comprobantes-electronicos" + ], "scripts": { "build": "tsup", + "prepublishOnly": "pnpm run build", "test": "vitest run", "lint": "eslint src/", "typecheck": "tsc --noEmit",