diff --git a/mcp-gateway/wave-mcp/src/tools/invoices.ts b/mcp-gateway/wave-mcp/src/tools/invoices.ts new file mode 100644 index 0000000..856d74d --- /dev/null +++ b/mcp-gateway/wave-mcp/src/tools/invoices.ts @@ -0,0 +1,458 @@ +import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; +import { z } from "zod"; +import { WaveClient } from "../services/wave-client.js"; +import { WaveInvoice } from "../types.js"; +import { DEFAULT_PAGE_SIZE, MAX_PAGE_SIZE } from "../constants.js"; + +interface InvoicesData { + business: { + invoices: { + pageInfo: { currentPage: number; totalPages: number; totalCount: number }; + edges: Array<{ node: WaveInvoice }>; + }; + }; +} + +interface InvoiceData { + business: { invoice: WaveInvoice }; +} + +interface InvoiceCreateData { + invoiceCreate: { + didSucceed: boolean; + inputErrors: Array<{ code: string; message: string; path: string[] }>; + invoice?: WaveInvoice; + }; +} + +interface InvoicePatchData { + invoicePatch: { + didSucceed: boolean; + inputErrors: Array<{ code: string; message: string; path: string[] }>; + invoice?: WaveInvoice; + }; +} + +interface InvoiceActionData { + invoiceApprove?: { didSucceed: boolean; inputErrors: Array<{ message: string }> }; + invoiceSend?: { didSucceed: boolean; inputErrors: Array<{ message: string }>; invoice?: { id: string } }; + invoiceMarkSent?: { didSucceed: boolean; inputErrors: Array<{ message: string }> }; + invoiceDelete?: { didSucceed: boolean; inputErrors: Array<{ message: string }> }; +} + +const INVOICE_FIELDS = ` + id pdfUrl viewUrl status title subhead invoiceNumber + invoiceDate dueDate memo + customer { id name email } + currency { code } + amountDue { value currency { code } } + amountPaid { value currency { code } } + taxTotal { value currency { code } } + subtotal { value currency { code } } + total { value currency { code } } + items { + product { id name } + description quantity unitPrice subtotal total + account { id name } + } + createdAt modifiedAt +`; + +function formatInvoiceMarkdown(inv: WaveInvoice): string[] { + const lines = [ + `## Invoice #${inv.invoiceNumber} — ${inv.customer?.name ?? "Unknown"} (${inv.id})`, + `- **Status**: ${inv.status}`, + `- **Date**: ${inv.invoiceDate ?? "N/A"}`, + `- **Due**: ${inv.dueDate ?? "N/A"}`, + `- **Total**: ${WaveClient.formatCurrency(inv.total?.value ?? 0, inv.currency?.code ?? "")}`, + `- **Amount Due**: ${WaveClient.formatCurrency(inv.amountDue?.value ?? 0, inv.currency?.code ?? "")}`, + `- **Amount Paid**: ${WaveClient.formatCurrency(inv.amountPaid?.value ?? 0, inv.currency?.code ?? "")}`, + ]; + if (inv.pdfUrl) lines.push(`- **PDF**: ${inv.pdfUrl}`); + if (inv.viewUrl) lines.push(`- **View**: ${inv.viewUrl}`); + lines.push(""); + return lines; +} + +export function registerInvoiceTools(server: McpServer, client: WaveClient): void { + // ── wave_list_invoices ──────────────────────────────────────────────────── + + server.registerTool( + "wave_list_invoices", + { + title: "List Wave Invoices", + description: `List invoices for a Wave business with optional filtering by status or customer. + +Args: + - businessId (string): Wave business ID + - page (number): Page number (default: 1) + - pageSize (number): Items per page, 1-100 (default: 20) + - customerId (string): Filter by customer ID (optional) + - invoiceNumber (string): Filter by invoice number (optional) + - status ('DRAFT' | 'APPROVED' | 'SENT' | 'VIEWED' | 'PARTIALLY_PAID' | 'PAID' | 'OVERDUE'): Filter by status (optional) + - response_format ('markdown' | 'json'): Output format (default: 'markdown') + +Returns: + Paginated list of invoices with totals, status, and customer info.`, + inputSchema: z.object({ + businessId: z.string().describe("Wave business ID"), + page: z.number().int().min(1).default(1).describe("Page number"), + pageSize: z.number().int().min(1).max(MAX_PAGE_SIZE).default(DEFAULT_PAGE_SIZE).describe("Items per page"), + customerId: z.string().optional().describe("Filter by customer ID"), + invoiceNumber: z.string().optional().describe("Filter by invoice number"), + status: z.enum(["DRAFT", "APPROVED", "SENT", "VIEWED", "PARTIALLY_PAID", "PAID", "OVERDUE"]).optional().describe("Filter by status"), + response_format: z.enum(["markdown", "json"]).default("markdown").describe("Output format"), + }), + annotations: { readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: true }, + }, + async ({ businessId, page, pageSize, customerId, invoiceNumber, status, response_format }) => { + try { + // Build optional filter variables + const vars: Record = { businessId, page, pageSize }; + if (customerId) vars.customerId = customerId; + if (invoiceNumber) vars.invoiceNumber = invoiceNumber; + if (status) vars.status = status; + + const data = await client.query(` + query($businessId: ID!, $page: Int!, $pageSize: Int!) { + business(id: $businessId) { + invoices(page: $page, pageSize: $pageSize) { + pageInfo { currentPage totalPages totalCount } + edges { node { ${INVOICE_FIELDS} } } + } + } + } + `, vars); + + let { pageInfo, edges } = data.business.invoices; + let invoices = edges.map((e) => e.node); + + // Client-side filter since Wave API may not support all filter params at query level + if (customerId) invoices = invoices.filter((i) => i.customer?.id === customerId); + if (status) invoices = invoices.filter((i) => i.status === status); + if (invoiceNumber) invoices = invoices.filter((i) => i.invoiceNumber?.includes(invoiceNumber)); + + if (!invoices.length) { + return { content: [{ type: "text", text: "No invoices found matching the criteria." }] }; + } + + const result = { + total: pageInfo.totalCount, + count: invoices.length, + page: pageInfo.currentPage, + totalPages: pageInfo.totalPages, + has_more: pageInfo.currentPage < pageInfo.totalPages, + next_page: pageInfo.currentPage < pageInfo.totalPages ? pageInfo.currentPage + 1 : undefined, + invoices, + }; + + let text: string; + if (response_format === "json") { + text = JSON.stringify(result, null, 2); + } else { + const lines = [`# Invoices (${pageInfo.totalCount} total, page ${pageInfo.currentPage}/${pageInfo.totalPages})`, ""]; + for (const inv of invoices) lines.push(...formatInvoiceMarkdown(inv)); + text = lines.join("\n"); + } + + return { + content: [{ type: "text", text: WaveClient.truncate(text) }], + structuredContent: result, + }; + } catch (error) { + return { content: [{ type: "text", text: `Error: ${error instanceof Error ? error.message : String(error)}` }] }; + } + } + ); + + // ── wave_get_invoice ────────────────────────────────────────────────────── + + server.registerTool( + "wave_get_invoice", + { + title: "Get Wave Invoice", + description: `Get full details for a specific invoice including line items. + +Args: + - businessId (string): Wave business ID + - invoiceId (string): Invoice ID + - response_format ('markdown' | 'json'): Output format (default: 'markdown') + +Returns: + Full invoice with all line items, totals, dates, and PDF/view links.`, + inputSchema: z.object({ + businessId: z.string().describe("Wave business ID"), + invoiceId: z.string().describe("Invoice ID"), + response_format: z.enum(["markdown", "json"]).default("markdown").describe("Output format"), + }), + annotations: { readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: true }, + }, + async ({ businessId, invoiceId, response_format }) => { + try { + const data = await client.query(` + query($businessId: ID!, $invoiceId: ID!) { + business(id: $businessId) { + invoice(id: $invoiceId) { ${INVOICE_FIELDS} } + } + } + `, { businessId, invoiceId }); + + const inv = data.business.invoice; + if (!inv) return { content: [{ type: "text", text: `No invoice found with ID: ${invoiceId}` }] }; + + let text: string; + if (response_format === "json") { + text = JSON.stringify(inv, null, 2); + } else { + const lines = [...formatInvoiceMarkdown(inv)]; + if (inv.items?.length) { + lines.push("### Line Items", ""); + for (const item of inv.items) { + const desc = item.description ?? item.product?.name ?? "Item"; + lines.push(`- ${desc}: ${item.quantity} × ${WaveClient.formatCurrency(item.unitPrice, inv.currency?.code ?? "")} = ${WaveClient.formatCurrency(item.total, inv.currency?.code ?? "")}`); + } + } + text = lines.join("\n"); + } + + return { content: [{ type: "text", text }], structuredContent: inv }; + } catch (error) { + return { content: [{ type: "text", text: `Error: ${error instanceof Error ? error.message : String(error)}` }] }; + } + } + ); + + // ── wave_create_invoice ─────────────────────────────────────────────────── + + server.registerTool( + "wave_create_invoice", + { + title: "Create Wave Invoice", + description: `Create a new draft invoice in Wave. + +Args: + - businessId (string): Wave business ID + - customerId (string): Customer ID for the invoice + - invoiceDate (string): Invoice date in YYYY-MM-DD format (optional, defaults to today) + - dueDate (string): Due date in YYYY-MM-DD format (optional) + - invoiceNumber (string): Custom invoice number (optional, auto-assigned if omitted) + - memo (string): Note to appear on invoice (optional) + - items (array): Line items (required, at least 1) + - productId (string): Product ID (optional) + - description (string): Item description + - quantity (number): Quantity + - unitPrice (number): Unit price + - accountId (string): Revenue account ID (optional) + - response_format ('markdown' | 'json'): Output format (default: 'markdown') + +Returns: + Created invoice details or validation errors.`, + inputSchema: z.object({ + businessId: z.string().describe("Wave business ID"), + customerId: z.string().describe("Customer ID"), + invoiceDate: z.string().regex(/^\d{4}-\d{2}-\d{2}$/).optional().describe("Invoice date (YYYY-MM-DD)"), + dueDate: z.string().regex(/^\d{4}-\d{2}-\d{2}$/).optional().describe("Due date (YYYY-MM-DD)"), + invoiceNumber: z.string().optional().describe("Custom invoice number"), + memo: z.string().optional().describe("Memo/note on invoice"), + items: z.array(z.object({ + productId: z.string().optional().describe("Product ID"), + description: z.string().optional().describe("Line item description"), + quantity: z.number().min(0.001).describe("Quantity"), + unitPrice: z.number().describe("Unit price"), + accountId: z.string().optional().describe("Revenue account ID"), + })).min(1).describe("Line items (at least 1 required)"), + response_format: z.enum(["markdown", "json"]).default("markdown").describe("Output format"), + }), + annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: false, openWorldHint: true }, + }, + async ({ businessId, customerId, invoiceDate, dueDate, invoiceNumber, memo, items, response_format }) => { + try { + const input: Record = { + businessId, + customerId, + items: items.map((item) => { + const li: Record = { + quantity: item.quantity, + unitPrice: item.unitPrice, + }; + if (item.productId) li.productId = item.productId; + if (item.description) li.description = item.description; + if (item.accountId) li.accountId = item.accountId; + return li; + }), + }; + if (invoiceDate) input.invoiceDate = invoiceDate; + if (dueDate) input.dueDate = dueDate; + if (invoiceNumber) input.invoiceNumber = invoiceNumber; + if (memo) input.memo = memo; + + const data = await client.query(` + mutation($input: InvoiceCreateInput!) { + invoiceCreate(input: $input) { + didSucceed + inputErrors { code message path } + invoice { ${INVOICE_FIELDS} } + } + } + `, { input }); + + const result = data.invoiceCreate; + if (!result.didSucceed) { + const errs = result.inputErrors.map((e) => ` - ${e.path?.join(".") ?? "field"}: ${e.message}`).join("\n"); + return { content: [{ type: "text", text: `Failed to create invoice:\n${errs}` }] }; + } + + let text: string; + if (response_format === "json") { + text = JSON.stringify(result.invoice, null, 2); + } else { + text = `Invoice created successfully!\n\n${formatInvoiceMarkdown(result.invoice!).join("\n")}`; + } + + return { content: [{ type: "text", text }], structuredContent: result.invoice }; + } catch (error) { + return { content: [{ type: "text", text: `Error: ${error instanceof Error ? error.message : String(error)}` }] }; + } + } + ); + + // ── wave_approve_invoice ────────────────────────────────────────────────── + + server.registerTool( + "wave_approve_invoice", + { + title: "Approve Wave Invoice", + description: `Approve a draft invoice, moving it to APPROVED status so it can be sent. + +Args: + - invoiceId (string): Invoice ID to approve + +Returns: + Success confirmation or error details.`, + inputSchema: z.object({ + invoiceId: z.string().describe("Invoice ID to approve"), + }), + annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: true, openWorldHint: true }, + }, + async ({ invoiceId }) => { + try { + const data = await client.query(` + mutation($input: InvoiceApproveInput!) { + invoiceApprove(input: $input) { + didSucceed + inputErrors { message } + } + } + `, { input: { invoiceId } }); + + const result = data.invoiceApprove!; + if (!result.didSucceed) { + const errs = result.inputErrors.map((e) => ` - ${e.message}`).join("\n"); + return { content: [{ type: "text", text: `Failed to approve invoice:\n${errs}` }] }; + } + + return { content: [{ type: "text", text: `Invoice ${invoiceId} approved successfully.` }] }; + } catch (error) { + return { content: [{ type: "text", text: `Error: ${error instanceof Error ? error.message : String(error)}` }] }; + } + } + ); + + // ── wave_send_invoice ───────────────────────────────────────────────────── + + server.registerTool( + "wave_send_invoice", + { + title: "Send Wave Invoice", + description: `Send an approved invoice to the customer via email. + +Args: + - invoiceId (string): Invoice ID to send + - to (array of strings): Recipient email addresses + - subject (string): Email subject (optional) + - message (string): Email message body (optional) + +Returns: + Success confirmation or error details.`, + inputSchema: z.object({ + invoiceId: z.string().describe("Invoice ID to send"), + to: z.array(z.string().email()).min(1).describe("Recipient email addresses"), + subject: z.string().optional().describe("Email subject line"), + message: z.string().optional().describe("Email message body"), + }), + annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: false, openWorldHint: true }, + }, + async ({ invoiceId, to, subject, message }) => { + try { + const input: Record = { invoiceId, to }; + if (subject) input.subject = subject; + if (message) input.message = message; + + const data = await client.query(` + mutation($input: InvoiceSendInput!) { + invoiceSend(input: $input) { + didSucceed + inputErrors { message } + } + } + `, { input }); + + const result = data.invoiceSend!; + if (!result.didSucceed) { + const errs = result.inputErrors.map((e) => ` - ${e.message}`).join("\n"); + return { content: [{ type: "text", text: `Failed to send invoice:\n${errs}` }] }; + } + + return { content: [{ type: "text", text: `Invoice ${invoiceId} sent to ${to.join(", ")}.` }] }; + } catch (error) { + return { content: [{ type: "text", text: `Error: ${error instanceof Error ? error.message : String(error)}` }] }; + } + } + ); + + // ── wave_delete_invoice ─────────────────────────────────────────────────── + + server.registerTool( + "wave_delete_invoice", + { + title: "Delete Wave Invoice", + description: `Delete an invoice. Only DRAFT invoices can be deleted; paid invoices cannot be deleted. + +⚠️ This action is irreversible. + +Args: + - businessId (string): Wave business ID + - invoiceId (string): Invoice ID to delete + +Returns: + Success confirmation or error details.`, + inputSchema: z.object({ + businessId: z.string().describe("Wave business ID"), + invoiceId: z.string().describe("Invoice ID to delete"), + }), + annotations: { readOnlyHint: false, destructiveHint: true, idempotentHint: false, openWorldHint: true }, + }, + async ({ businessId, invoiceId }) => { + try { + const data = await client.query(` + mutation($input: InvoiceDeleteInput!) { + invoiceDelete(input: $input) { + didSucceed + inputErrors { message } + } + } + `, { input: { businessId, invoiceId } }); + + const result = data.invoiceDelete!; + if (!result.didSucceed) { + const errs = result.inputErrors.map((e) => ` - ${e.message}`).join("\n"); + return { content: [{ type: "text", text: `Failed to delete invoice:\n${errs}` }] }; + } + + return { content: [{ type: "text", text: `Invoice ${invoiceId} deleted successfully.` }] }; + } catch (error) { + return { content: [{ type: "text", text: `Error: ${error instanceof Error ? error.message : String(error)}` }] }; + } + } + ); +}