mcp-servers/mcp-gateway/wave-mcp/src/tools/invoices.ts

458 lines
18 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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<string, unknown> = { businessId, page, pageSize };
if (customerId) vars.customerId = customerId;
if (invoiceNumber) vars.invoiceNumber = invoiceNumber;
if (status) vars.status = status;
const data = await client.query<InvoicesData>(`
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<InvoiceData>(`
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<string, unknown> = {
businessId,
customerId,
items: items.map((item) => {
const li: Record<string, unknown> = {
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<InvoiceCreateData>(`
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<InvoiceActionData>(`
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<string, unknown> = { invoiceId, to };
if (subject) input.subject = subject;
if (message) input.message = message;
const data = await client.query<InvoiceActionData>(`
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<InvoiceActionData>(`
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)}` }] };
}
}
);
}