Files
hermes-mcp/src/billing/invoices.test.ts
Garfield 61dab40585 feat(saas): SquareMCP v2 — multi-tenant MCP platform complete
Steps 0–10 of the v2 plan, 194 tests passing.

Core infrastructure
- Shared Redis client (src/redis.ts); all four Redis consumers migrated
- Vitest test harness with vitest.config.ts and npm test/test:watch scripts

Billing & invoicing (Steps 1–2)
- Monthly invoice generation with idempotency (MySQL uq_customer_period unique key)
- Cron job with Redis distributed lock (Lua compare-delete, 1-hr TTL)
- Invoice emailer via nodemailer (FETCHERPAY SMTP)
- Billing middleware: checkLimit gate in handleToolCall; platform attribution fix

Email multi-tenancy (Step 3)
- EmailCtx = Account | EmailCredentials; imap.ts + smtp.ts accept both
- resolveEmailCtx helper in tools.ts; all email tools use customer credentials

Analytics + platform health (Steps 4–5)
- Chart.js bar charts for platform breakdown and daily activity
- Token expiry check in getCredential with dynamic import refresh
- platform-health.ts: per-platform health probe with 10-min Redis cache
- GET /api/health/platforms; "Token expired" amber badge in dashboard

Tool schema filtering (Step 6)
- stripAccountParam deep-clones tool schemas; multi-tenant sessions never
  see the internal account enum

OAuth hardening (Step 7)
- Atomic auth code consumption: UPDATE SET used=TRUE, check affectedRows
- customer_id threaded through oauth_auth_codes → oauth_tokens
- getTokenCustomer(); requireAuth resolves req.customer from Bearer token
- Consent page requires authenticated session; redirect_uri validated
  against registered URIs; http://localhost:* loopback wildcard

DCR browser flow (Step 8)
- ensureOAuthAppRegistered() upserts pre-registered SquareMCP OAuth app
  on startup with redirect URIs for mcp-callback, localhost:*, claude-desktop,
  opencode
- GET /oauth/connect-mcp → server-side redirect (client_id off frontend)
- GET /oauth/mcp-callback → exchanges code, renders config snippet page
  with copy buttons for Claude Desktop and Codex CLI

Webhooks (Step 9)
- webhook_url + webhook_secret columns on customers
- deliverWebhook(): HMAC-SHA256 signing, 3× exponential retry (1s/4s/16s),
  Redis DLQ with 7-day TTL on total failure
- isValidWebhookUrl(): SSRF protection (blocks RFC-1918, localhost, .local)
- POST /api/webhooks/config (secret returned once), GET, DELETE
- GET /api/admin/webhooks/dlq/:customerId
- WhatsApp POST route uses express.raw() for raw body preservation
- Dashboard Webhooks tab with secret-once display and copy button

Developer docs (Step 10)
- docs/ static HTML site (GitHub Pages, no build pipeline)
- index.html: landing page with client + platform overview
- getting-started.html: tabbed MCP config for Claude Desktop, Codex CLI, opencode
- platforms.html: LinkedIn, TikTok, WhatsApp, Instagram, Twitter, Telegram guides
- agent-tutorial.html: complete Node.js agent (Anthropic SDK + MCP SDK),
  LinkedIn posting loop, extensions for multi-platform + inbound webhook reaction

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-13 23:43:56 -04:00

152 lines
6.1 KiB
TypeScript

import { describe, it, expect, vi, beforeEach } from 'vitest';
import nodemailer from 'nodemailer';
const mockQuery = vi.fn();
vi.mock('../db.js', () => ({
getPool: () => ({ query: mockQuery }),
}));
vi.mock('nodemailer', () => ({
default: { createTransport: vi.fn() },
}));
import {
createInvoice,
generateMonthlyInvoice,
getInvoiceByNumber,
emailInvoice,
type Invoice,
} from './invoices.js';
const baseInvoice: Invoice = {
id: 1,
customer_id: 'cust-1',
invoice_number: 'SMCP-20260501-aabb1122',
amount: 25.0,
currency: 'USD',
status: 'draft',
period_start: '2026-04-01',
period_end: '2026-04-30',
line_items: [{ description: 'email actions', quantity: 500, unit_price: 0.05, amount: 25.0 }],
sent_at: null,
paid_at: null,
created_at: '2026-05-01T00:00:00Z',
constructor: { name: 'RowDataPacket' } as any,
};
beforeEach(() => {
vi.clearAllMocks();
});
// ── generateInvoiceNumber format ─────────────────────────────────────────────
describe('generateMonthlyInvoice — billing period', () => {
it('queries previous month, not current', async () => {
mockQuery.mockResolvedValueOnce([[]]); // usage query returns empty
await generateMonthlyInvoice('cust-1');
const sql: string = mockQuery.mock.calls[0][0];
// Must reference DATE_SUB for the start of the previous month
expect(sql).toContain('DATE_SUB');
// Must NOT use a plain NOW() as the lower bound (that would be current month)
expect(sql).not.toMatch(/>=\s*DATE_FORMAT\(NOW\(\)/);
});
it('returns null when customer has zero usage', async () => {
mockQuery.mockResolvedValueOnce([[]]); // no usage rows
const result = await generateMonthlyInvoice('cust-1');
expect(result).toBeNull();
});
it('creates invoice for previous month period', async () => {
const now = new Date();
const expectedStart = new Date(now.getFullYear(), now.getMonth() - 1, 1)
.toISOString().slice(0, 10);
mockQuery
.mockResolvedValueOnce([[{ platform: 'email', count: 100 }]]) // usage
.mockResolvedValueOnce([[]]) // insert
.mockResolvedValueOnce([[{ ...baseInvoice, period_start: expectedStart }]]); // select after insert
const invoice = await generateMonthlyInvoice('cust-1');
expect(invoice?.period_start).toBe(expectedStart);
});
});
// ── Invoice number format ─────────────────────────────────────────────────────
describe('createInvoice — invoice number', () => {
it('generates hex suffix, not decimal padded', async () => {
mockQuery
.mockResolvedValueOnce([[]]) // insert
.mockResolvedValueOnce([[baseInvoice]]); // select
await createInvoice('cust-1', 25, baseInvoice.line_items, '2026-04-01', '2026-04-30');
const insertSql: string = mockQuery.mock.calls[0][0];
const invoiceNumber: string = mockQuery.mock.calls[0][1][1];
// Should be SMCP-YYYYMMDD-<8 hex chars>
expect(invoiceNumber).toMatch(/^SMCP-\d{8}-[0-9a-f]{8}$/);
});
});
// ── Idempotency ───────────────────────────────────────────────────────────────
describe('createInvoice — idempotency', () => {
it('returns existing invoice on uq_customer_period duplicate', async () => {
const dupError = Object.assign(new Error("Duplicate entry 'cust-1-2026-04-01' for key 'invoices.uq_customer_period'"), { errno: 1062 });
mockQuery
.mockRejectedValueOnce(dupError) // INSERT throws 1062
.mockResolvedValueOnce([[baseInvoice]]); // SELECT existing
const invoice = await createInvoice('cust-1', 25, baseInvoice.line_items, '2026-04-01', '2026-04-30');
expect(invoice.invoice_number).toBe(baseInvoice.invoice_number);
expect(invoice.customer_id).toBe('cust-1');
});
it('re-throws 1062 for other constraints (e.g. duplicate invoice_number)', async () => {
const dupError = Object.assign(new Error("Duplicate entry 'SMCP-...' for key 'invoices.invoice_number'"), { errno: 1062 });
mockQuery.mockRejectedValueOnce(dupError);
await expect(createInvoice('cust-1', 25, baseInvoice.line_items, '2026-04-01', '2026-04-30'))
.rejects.toThrow('invoice_number');
});
});
// ── line_items JSON normalization ─────────────────────────────────────────────
describe('getInvoiceByNumber — line_items normalization', () => {
it('parses line_items when returned as JSON string', async () => {
const rawInvoice = {
...baseInvoice,
line_items: JSON.stringify(baseInvoice.line_items) as any,
};
mockQuery.mockResolvedValueOnce([[rawInvoice]]);
const invoice = await getInvoiceByNumber('SMCP-20260501-aabb1122');
expect(Array.isArray(invoice?.line_items)).toBe(true);
expect(invoice?.line_items[0].description).toBe('email actions');
});
it('leaves line_items unchanged when already an array', async () => {
mockQuery.mockResolvedValueOnce([[baseInvoice]]);
const invoice = await getInvoiceByNumber('SMCP-20260501-aabb1122');
expect(Array.isArray(invoice?.line_items)).toBe(true);
});
});
// ── emailInvoice ─────────────────────────────────────────────────────────────
describe('emailInvoice', () => {
it('sends mail with invoice_number in subject and HTML body', async () => {
const sendMail = vi.fn().mockResolvedValue({ messageId: '<abc@mail>' });
vi.mocked(nodemailer.createTransport).mockReturnValue({ sendMail } as any);
await emailInvoice(baseInvoice, 'customer@example.com');
expect(sendMail).toHaveBeenCalledOnce();
const mailArgs = sendMail.mock.calls[0][0];
expect(mailArgs.to).toBe('customer@example.com');
expect(mailArgs.subject).toContain(baseInvoice.invoice_number);
expect(mailArgs.html).toContain(baseInvoice.invoice_number);
});
});