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>
This commit is contained in:
96
src/billing/cron.test.ts
Normal file
96
src/billing/cron.test.ts
Normal file
@@ -0,0 +1,96 @@
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
|
||||
const { mockRedisSet, mockRedisEval } = vi.hoisted(() => ({
|
||||
mockRedisSet: vi.fn(),
|
||||
mockRedisEval: vi.fn(),
|
||||
}));
|
||||
|
||||
const { mockQuery } = vi.hoisted(() => ({ mockQuery: vi.fn() }));
|
||||
|
||||
const { mockGenerateMonthlyInvoice } = vi.hoisted(() => ({
|
||||
mockGenerateMonthlyInvoice: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock('../redis.js', () => ({
|
||||
default: { set: mockRedisSet, eval: mockRedisEval },
|
||||
}));
|
||||
|
||||
vi.mock('../db.js', () => ({
|
||||
getPool: () => ({ query: mockQuery }),
|
||||
}));
|
||||
|
||||
vi.mock('./invoices.js', () => ({
|
||||
generateMonthlyInvoice: (...args: any[]) => mockGenerateMonthlyInvoice(...args),
|
||||
}));
|
||||
|
||||
import { runInvoiceCron } from './cron.js';
|
||||
|
||||
const TWO_CUSTOMERS = [[{ id: 'cust-a' }, { id: 'cust-b' }]];
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
mockRedisEval.mockResolvedValue(1);
|
||||
mockGenerateMonthlyInvoice.mockResolvedValue(null);
|
||||
});
|
||||
|
||||
describe('runInvoiceCron — Redis lock', () => {
|
||||
it('runs invoice loop when lock is acquired', async () => {
|
||||
mockRedisSet.mockResolvedValue('OK');
|
||||
mockQuery.mockResolvedValue(TWO_CUSTOMERS);
|
||||
|
||||
await runInvoiceCron();
|
||||
|
||||
expect(mockRedisSet).toHaveBeenCalledWith(
|
||||
'invoice:cron:lock',
|
||||
expect.any(String),
|
||||
{ NX: true, EX: 3600 }
|
||||
);
|
||||
expect(mockQuery).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('skips invoice loop when lock is already held', async () => {
|
||||
mockRedisSet.mockResolvedValue(null);
|
||||
|
||||
await runInvoiceCron();
|
||||
|
||||
expect(mockQuery).not.toHaveBeenCalled();
|
||||
expect(mockGenerateMonthlyInvoice).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('releases lock via compare-delete Lua script after success', async () => {
|
||||
mockRedisSet.mockResolvedValue('OK');
|
||||
mockQuery.mockResolvedValue([[{ id: 'cust-1' }]]);
|
||||
|
||||
await runInvoiceCron();
|
||||
|
||||
expect(mockRedisEval).toHaveBeenCalledWith(
|
||||
expect.stringContaining('redis.call("get"'),
|
||||
expect.objectContaining({ keys: ['invoice:cron:lock'], arguments: [expect.any(String)] })
|
||||
);
|
||||
});
|
||||
|
||||
it('does NOT release lock when the loop throws', async () => {
|
||||
mockRedisSet.mockResolvedValue('OK');
|
||||
mockQuery.mockRejectedValue(new Error('DB down'));
|
||||
|
||||
await expect(runInvoiceCron()).rejects.toThrow('DB down');
|
||||
expect(mockRedisEval).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('runInvoiceCron — per-customer error isolation', () => {
|
||||
it('continues processing remaining customers after one fails', async () => {
|
||||
mockRedisSet.mockResolvedValue('OK');
|
||||
mockQuery.mockResolvedValue([[{ id: 'cust-a' }, { id: 'cust-b' }, { id: 'cust-c' }]]);
|
||||
|
||||
mockGenerateMonthlyInvoice
|
||||
.mockResolvedValueOnce({ invoice_number: 'SMCP-1', customer_id: 'cust-a' })
|
||||
.mockRejectedValueOnce(new Error('Stripe error'))
|
||||
.mockResolvedValueOnce({ invoice_number: 'SMCP-3', customer_id: 'cust-c' });
|
||||
|
||||
await runInvoiceCron();
|
||||
|
||||
expect(mockGenerateMonthlyInvoice).toHaveBeenCalledTimes(3);
|
||||
expect(mockRedisEval).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
51
src/billing/cron.ts
Normal file
51
src/billing/cron.ts
Normal file
@@ -0,0 +1,51 @@
|
||||
import { randomUUID } from 'crypto';
|
||||
import redis from '../redis.js';
|
||||
import { getPool } from '../db.js';
|
||||
import { generateMonthlyInvoice } from './invoices.js';
|
||||
|
||||
const LOCK_KEY = 'invoice:cron:lock';
|
||||
const LOCK_TTL_SECONDS = 3600;
|
||||
|
||||
// Only releases the lock if the value matches — prevents one replica from
|
||||
// deleting another's lock after a TTL expiry race.
|
||||
const COMPARE_DELETE_LUA = `
|
||||
if redis.call("get", KEYS[1]) == ARGV[1] then
|
||||
return redis.call("del", KEYS[1])
|
||||
else
|
||||
return 0
|
||||
end
|
||||
`;
|
||||
|
||||
export async function runInvoiceCron(): Promise<void> {
|
||||
const lockValue = randomUUID();
|
||||
|
||||
const acquired = await redis.set(LOCK_KEY, lockValue, { NX: true, EX: LOCK_TTL_SECONDS });
|
||||
if (!acquired) {
|
||||
console.log('[cron] Another process holds the invoice lock — skipping');
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const [customers] = await getPool().query<any[]>(
|
||||
"SELECT id FROM customers WHERE active = TRUE AND plan != 'free'"
|
||||
);
|
||||
|
||||
for (const customer of customers) {
|
||||
try {
|
||||
const invoice = await generateMonthlyInvoice(customer.id);
|
||||
if (invoice) {
|
||||
console.log(`[cron] Generated invoice ${invoice.invoice_number} for customer ${customer.id}`);
|
||||
}
|
||||
} catch (err) {
|
||||
// One customer failing must not abort the rest
|
||||
console.error(`[cron] Failed for customer ${customer.id}:`, err);
|
||||
}
|
||||
}
|
||||
|
||||
await redis.eval(COMPARE_DELETE_LUA, { keys: [LOCK_KEY], arguments: [lockValue] });
|
||||
} catch (err) {
|
||||
// Leave the lock in place on unexpected failure — TTL releases it after 1 hour
|
||||
console.error('[cron] Invoice cron fatal error:', err);
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
151
src/billing/invoices.test.ts
Normal file
151
src/billing/invoices.test.ts
Normal file
@@ -0,0 +1,151 @@
|
||||
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);
|
||||
});
|
||||
});
|
||||
@@ -1,3 +1,5 @@
|
||||
import { randomBytes } from 'crypto';
|
||||
import nodemailer from 'nodemailer';
|
||||
import { getPool } from '../db.js';
|
||||
import type { RowDataPacket } from 'mysql2';
|
||||
|
||||
@@ -24,10 +26,16 @@ export interface Invoice extends RowDataPacket {
|
||||
}
|
||||
|
||||
function generateInvoiceNumber(): string {
|
||||
const prefix = 'SMCP';
|
||||
const date = new Date().toISOString().slice(0, 10).replace(/-/g, '');
|
||||
const random = Math.floor(Math.random() * 10000).toString().padStart(4, '0');
|
||||
return `${prefix}-${date}-${random}`;
|
||||
const suffix = randomBytes(4).toString('hex');
|
||||
return `SMCP-${date}-${suffix}`;
|
||||
}
|
||||
|
||||
function normalizeInvoice(invoice: Invoice): Invoice {
|
||||
if (typeof invoice.line_items === 'string') {
|
||||
invoice.line_items = JSON.parse(invoice.line_items);
|
||||
}
|
||||
return invoice;
|
||||
}
|
||||
|
||||
export async function createInvoice(
|
||||
@@ -38,16 +46,27 @@ export async function createInvoice(
|
||||
periodEnd: string
|
||||
): Promise<Invoice> {
|
||||
const invoiceNumber = generateInvoiceNumber();
|
||||
await getPool().query(
|
||||
`INSERT INTO invoices (customer_id, invoice_number, amount, line_items, period_start, period_end)
|
||||
VALUES (?, ?, ?, ?, ?, ?)`,
|
||||
[customerId, invoiceNumber, amount, JSON.stringify(lineItems), periodStart, periodEnd]
|
||||
);
|
||||
try {
|
||||
await getPool().query(
|
||||
`INSERT INTO invoices (customer_id, invoice_number, amount, line_items, period_start, period_end)
|
||||
VALUES (?, ?, ?, ?, ?, ?)`,
|
||||
[customerId, invoiceNumber, amount, JSON.stringify(lineItems), periodStart, periodEnd]
|
||||
);
|
||||
} catch (err: any) {
|
||||
if (err.errno === 1062 && err.message.includes('uq_customer_period')) {
|
||||
const [rows] = await getPool().query<Invoice[]>(
|
||||
'SELECT * FROM invoices WHERE customer_id = ? AND period_start = ?',
|
||||
[customerId, periodStart]
|
||||
);
|
||||
return normalizeInvoice(rows[0]);
|
||||
}
|
||||
throw err;
|
||||
}
|
||||
const [rows] = await getPool().query<Invoice[]>(
|
||||
'SELECT * FROM invoices WHERE invoice_number = ?',
|
||||
[invoiceNumber]
|
||||
);
|
||||
return rows[0];
|
||||
return normalizeInvoice(rows[0]);
|
||||
}
|
||||
|
||||
export async function getCustomerInvoices(customerId: string): Promise<Invoice[]> {
|
||||
@@ -55,7 +74,7 @@ export async function getCustomerInvoices(customerId: string): Promise<Invoice[]
|
||||
'SELECT * FROM invoices WHERE customer_id = ? ORDER BY created_at DESC',
|
||||
[customerId]
|
||||
);
|
||||
return rows;
|
||||
return rows.map(normalizeInvoice);
|
||||
}
|
||||
|
||||
export async function getInvoiceByNumber(invoiceNumber: string): Promise<Invoice | null> {
|
||||
@@ -63,7 +82,7 @@ export async function getInvoiceByNumber(invoiceNumber: string): Promise<Invoice
|
||||
'SELECT * FROM invoices WHERE invoice_number = ?',
|
||||
[invoiceNumber]
|
||||
);
|
||||
return rows[0] ?? null;
|
||||
return rows[0] ? normalizeInvoice(rows[0]) : null;
|
||||
}
|
||||
|
||||
export async function markInvoiceSent(invoiceNumber: string): Promise<void> {
|
||||
@@ -80,12 +99,45 @@ export async function markInvoicePaid(invoiceNumber: string): Promise<void> {
|
||||
);
|
||||
}
|
||||
|
||||
export async function emailInvoice(invoice: Invoice, toEmail: string): Promise<void> {
|
||||
const transport = nodemailer.createTransport({
|
||||
host: process.env.FETCHERPAY_SMTP_HOST ?? 'mail.fetcherpay.com',
|
||||
port: parseInt(process.env.FETCHERPAY_SMTP_PORT ?? '30587', 10),
|
||||
secure: false,
|
||||
auth: {
|
||||
user: process.env.BILLING_EMAIL ?? process.env.FETCHERPAY_EMAIL,
|
||||
pass: process.env.BILLING_PASSWORD ?? process.env.FETCHERPAY_PASSWORD,
|
||||
},
|
||||
tls: { rejectUnauthorized: false },
|
||||
});
|
||||
await transport.sendMail({
|
||||
from: process.env.BILLING_FROM ?? 'billing@squaremcp.com',
|
||||
to: toEmail,
|
||||
subject: `Invoice ${invoice.invoice_number} from SquareMCP`,
|
||||
html: `
|
||||
<h1>Invoice ${invoice.invoice_number}</h1>
|
||||
<p>Billing period: ${invoice.period_start} – ${invoice.period_end}</p>
|
||||
<p>Amount due: $${invoice.amount} ${invoice.currency}</p>
|
||||
<table>
|
||||
<tr><th>Description</th><th>Qty</th><th>Unit price</th><th>Amount</th></tr>
|
||||
${invoice.line_items.map((li) =>
|
||||
`<tr><td>${li.description}</td><td>${li.quantity}</td><td>$${li.unit_price}</td><td>$${li.amount}</td></tr>`
|
||||
).join('')}
|
||||
</table>
|
||||
`,
|
||||
});
|
||||
}
|
||||
|
||||
export async function generateMonthlyInvoice(customerId: string): Promise<Invoice | null> {
|
||||
const now = new Date();
|
||||
const prevMonthStart = new Date(now.getFullYear(), now.getMonth() - 1, 1);
|
||||
const prevMonthEnd = new Date(now.getFullYear(), now.getMonth(), 0);
|
||||
|
||||
const [usageRows] = await getPool().query<any[]>(
|
||||
`SELECT platform, COUNT(*) as count FROM usage_logs
|
||||
WHERE customer_id = ?
|
||||
AND created_at >= DATE_FORMAT(NOW(), '%Y-%m-01')
|
||||
AND created_at < DATE_FORMAT(DATE_ADD(NOW(), INTERVAL 1 MONTH), '%Y-%m-01')
|
||||
AND created_at >= DATE_FORMAT(DATE_SUB(NOW(), INTERVAL 1 MONTH), '%Y-%m-01')
|
||||
AND created_at < DATE_FORMAT(NOW(), '%Y-%m-01')
|
||||
GROUP BY platform`,
|
||||
[customerId]
|
||||
);
|
||||
@@ -97,7 +149,7 @@ export async function generateMonthlyInvoice(customerId: string): Promise<Invoic
|
||||
|
||||
for (const row of usageRows) {
|
||||
const qty = row.count;
|
||||
const unitPrice = 0.05; // $0.05 per action
|
||||
const unitPrice = 0.05;
|
||||
const amount = qty * unitPrice;
|
||||
total += amount;
|
||||
lineItems.push({
|
||||
@@ -108,15 +160,11 @@ export async function generateMonthlyInvoice(customerId: string): Promise<Invoic
|
||||
});
|
||||
}
|
||||
|
||||
const now = new Date();
|
||||
const start = new Date(now.getFullYear(), now.getMonth(), 1);
|
||||
const end = new Date(now.getFullYear(), now.getMonth() + 1, 0);
|
||||
|
||||
return createInvoice(
|
||||
customerId,
|
||||
parseFloat(total.toFixed(2)),
|
||||
lineItems,
|
||||
start.toISOString().slice(0, 10),
|
||||
end.toISOString().slice(0, 10)
|
||||
prevMonthStart.toISOString().slice(0, 10),
|
||||
prevMonthEnd.toISOString().slice(0, 10)
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,13 +1,10 @@
|
||||
import { createClient } from 'redis';
|
||||
import { RowDataPacket } from 'mysql2';
|
||||
import { getPool } from '../db.js';
|
||||
import { getCredential, Platform, PlatformCredentials } from '../multitenancy/credential-store.js';
|
||||
import type { PlanKey } from './plans.js';
|
||||
import type { Request, Response, NextFunction } from 'express';
|
||||
import { verifyJWT } from '../auth.js';
|
||||
|
||||
const redis = createClient({ url: process.env.REDIS_URL });
|
||||
redis.connect().catch((err) => console.error('[billing] Redis connect error:', err));
|
||||
import redis from '../redis.js';
|
||||
|
||||
export interface Customer {
|
||||
id: string;
|
||||
|
||||
@@ -20,11 +20,11 @@ export const PLANS: Record<PlanKey, Plan> = {
|
||||
growth: {
|
||||
name: 'Growth',
|
||||
monthlyCallLimit: 10000,
|
||||
platforms: ['email', 'obsidian', 'whatsapp', 'telegram', 'discord', 'instagram', 'linkedin', 'twitter'],
|
||||
platforms: ['email', 'obsidian', 'whatsapp', 'telegram', 'discord', 'instagram', 'linkedin', 'twitter', 'tiktok', 'facebook', 'snapchat'],
|
||||
},
|
||||
enterprise: {
|
||||
name: 'Enterprise',
|
||||
monthlyCallLimit: -1,
|
||||
platforms: ['email', 'obsidian', 'whatsapp', 'telegram', 'discord', 'instagram', 'linkedin', 'twitter'],
|
||||
platforms: ['email', 'obsidian', 'whatsapp', 'telegram', 'discord', 'instagram', 'linkedin', 'twitter', 'tiktok', 'facebook', 'snapchat'],
|
||||
},
|
||||
};
|
||||
|
||||
47
src/billing/usage.test.ts
Normal file
47
src/billing/usage.test.ts
Normal file
@@ -0,0 +1,47 @@
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
import { checkLimit } from './usage.js';
|
||||
|
||||
vi.mock('../db.js', () => ({
|
||||
getPool: vi.fn(() => ({
|
||||
query: vi.fn().mockResolvedValue([[{ count: 50 }]]),
|
||||
})),
|
||||
}));
|
||||
|
||||
describe('checkLimit', () => {
|
||||
it('always allows enterprise plan regardless of usage', async () => {
|
||||
const result = await checkLimit('cust-1', 'enterprise');
|
||||
expect(result).toEqual({ allowed: true, limit: -1, used: 0 });
|
||||
});
|
||||
|
||||
it('allows when usage is under limit', async () => {
|
||||
const { getPool } = await import('../db.js');
|
||||
vi.mocked(getPool).mockReturnValue({ query: vi.fn().mockResolvedValue([[{ count: 50 }]]) } as any);
|
||||
const result = await checkLimit('cust-1', 'growth');
|
||||
expect(result.allowed).toBe(true);
|
||||
expect(result.used).toBe(50);
|
||||
expect(result.limit).toBe(10000);
|
||||
});
|
||||
|
||||
it('blocks when usage equals limit', async () => {
|
||||
const { getPool } = await import('../db.js');
|
||||
vi.mocked(getPool).mockReturnValue({ query: vi.fn().mockResolvedValue([[{ count: 1000 }]]) } as any);
|
||||
const result = await checkLimit('cust-1', 'starter');
|
||||
expect(result.allowed).toBe(false);
|
||||
expect(result.used).toBe(1000);
|
||||
});
|
||||
|
||||
it('blocks when usage exceeds limit', async () => {
|
||||
const { getPool } = await import('../db.js');
|
||||
vi.mocked(getPool).mockReturnValue({ query: vi.fn().mockResolvedValue([[{ count: 1001 }]]) } as any);
|
||||
const result = await checkLimit('cust-1', 'free');
|
||||
expect(result.allowed).toBe(false);
|
||||
});
|
||||
|
||||
it('does not query DB for enterprise plan', async () => {
|
||||
const { getPool } = await import('../db.js');
|
||||
const querySpy = vi.fn();
|
||||
vi.mocked(getPool).mockReturnValue({ query: querySpy } as any);
|
||||
await checkLimit('cust-1', 'enterprise');
|
||||
expect(querySpy).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user