feat(saas): full SquareMCP SaaS platform v1
- JWT auth with bcrypt password hashing, cookie sessions, forgot/reset password - Per-user encrypted credential storage (Redis + AES-256-GCM) for all 9 platforms - Usage tracking with monthly limits per plan (free/starter/growth/enterprise) - Invoice generation and retrieval (admin + user views) - Admin panel with customer listing (role-based access) - Web app UI at app.squaremcp.com — login, dashboard, connections, usage, invoices - Unified auth middleware: API key, OAuth Bearer, and JWT cookie support - Facebook Graph API fixes: published_posts endpoint, photo/video post support - TikTok sandbox compliance: SELF_ONLY privacy for unaudited apps - URL verification files for TikTok app review
This commit is contained in:
122
src/billing/invoices.ts
Normal file
122
src/billing/invoices.ts
Normal file
@@ -0,0 +1,122 @@
|
||||
import { getPool } from '../db.js';
|
||||
import type { RowDataPacket } from 'mysql2';
|
||||
|
||||
export interface InvoiceLineItem {
|
||||
description: string;
|
||||
quantity: number;
|
||||
unit_price: number;
|
||||
amount: number;
|
||||
}
|
||||
|
||||
export interface Invoice extends RowDataPacket {
|
||||
id: number;
|
||||
customer_id: string;
|
||||
invoice_number: string;
|
||||
amount: number;
|
||||
currency: string;
|
||||
status: 'draft' | 'sent' | 'paid' | 'overdue';
|
||||
period_start: string;
|
||||
period_end: string;
|
||||
line_items: InvoiceLineItem[];
|
||||
sent_at: string | null;
|
||||
paid_at: string | null;
|
||||
created_at: string;
|
||||
}
|
||||
|
||||
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}`;
|
||||
}
|
||||
|
||||
export async function createInvoice(
|
||||
customerId: string,
|
||||
amount: number,
|
||||
lineItems: InvoiceLineItem[],
|
||||
periodStart: string,
|
||||
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]
|
||||
);
|
||||
const [rows] = await getPool().query<Invoice[]>(
|
||||
'SELECT * FROM invoices WHERE invoice_number = ?',
|
||||
[invoiceNumber]
|
||||
);
|
||||
return rows[0];
|
||||
}
|
||||
|
||||
export async function getCustomerInvoices(customerId: string): Promise<Invoice[]> {
|
||||
const [rows] = await getPool().query<Invoice[]>(
|
||||
'SELECT * FROM invoices WHERE customer_id = ? ORDER BY created_at DESC',
|
||||
[customerId]
|
||||
);
|
||||
return rows;
|
||||
}
|
||||
|
||||
export async function getInvoiceByNumber(invoiceNumber: string): Promise<Invoice | null> {
|
||||
const [rows] = await getPool().query<Invoice[]>(
|
||||
'SELECT * FROM invoices WHERE invoice_number = ?',
|
||||
[invoiceNumber]
|
||||
);
|
||||
return rows[0] ?? null;
|
||||
}
|
||||
|
||||
export async function markInvoiceSent(invoiceNumber: string): Promise<void> {
|
||||
await getPool().query(
|
||||
"UPDATE invoices SET status = 'sent', sent_at = NOW() WHERE invoice_number = ?",
|
||||
[invoiceNumber]
|
||||
);
|
||||
}
|
||||
|
||||
export async function markInvoicePaid(invoiceNumber: string): Promise<void> {
|
||||
await getPool().query(
|
||||
"UPDATE invoices SET status = 'paid', paid_at = NOW() WHERE invoice_number = ?",
|
||||
[invoiceNumber]
|
||||
);
|
||||
}
|
||||
|
||||
export async function generateMonthlyInvoice(customerId: string): Promise<Invoice | null> {
|
||||
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')
|
||||
GROUP BY platform`,
|
||||
[customerId]
|
||||
);
|
||||
|
||||
if (!usageRows.length) return null;
|
||||
|
||||
const lineItems: InvoiceLineItem[] = [];
|
||||
let total = 0;
|
||||
|
||||
for (const row of usageRows) {
|
||||
const qty = row.count;
|
||||
const unitPrice = 0.05; // $0.05 per action
|
||||
const amount = qty * unitPrice;
|
||||
total += amount;
|
||||
lineItems.push({
|
||||
description: `${row.platform} actions`,
|
||||
quantity: qty,
|
||||
unit_price: unitPrice,
|
||||
amount: parseFloat(amount.toFixed(2)),
|
||||
});
|
||||
}
|
||||
|
||||
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)
|
||||
);
|
||||
}
|
||||
@@ -4,6 +4,7 @@ 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));
|
||||
@@ -24,16 +25,22 @@ interface CustomerRow extends RowDataPacket {
|
||||
email: string;
|
||||
}
|
||||
|
||||
async function resolveCustomer(apiKey: string): Promise<Customer | null> {
|
||||
function buildCustomer(row: CustomerRow): Customer {
|
||||
return {
|
||||
id: row.id,
|
||||
plan: row.plan,
|
||||
active: Boolean(row.active),
|
||||
email: row.email,
|
||||
getCredential: <T extends PlatformCredentials>(platform: Platform) =>
|
||||
getCredential<T>(row.id, platform),
|
||||
};
|
||||
}
|
||||
|
||||
export async function resolveCustomerByApiKey(apiKey: string): Promise<Customer | null> {
|
||||
const cached = await redis.get(`customer:apikey:${apiKey}`);
|
||||
if (cached) {
|
||||
const base = JSON.parse(cached) as Omit<Customer, 'getCredential'>;
|
||||
// Re-attach the credential loader (functions can't be cached)
|
||||
return {
|
||||
...base,
|
||||
getCredential: <T extends PlatformCredentials>(platform: Platform) =>
|
||||
getCredential<T>(base.id, platform),
|
||||
};
|
||||
const base = JSON.parse(cached) as Omit<CustomerRow, 'getCredential'>;
|
||||
return buildCustomer(base as CustomerRow);
|
||||
}
|
||||
|
||||
const [rows] = await getPool().query<CustomerRow[]>(
|
||||
@@ -42,50 +49,80 @@ async function resolveCustomer(apiKey: string): Promise<Customer | null> {
|
||||
);
|
||||
if (!rows.length) return null;
|
||||
|
||||
const { id, plan, active, email } = rows[0];
|
||||
const customer: Customer = {
|
||||
id,
|
||||
plan,
|
||||
active: Boolean(active),
|
||||
email,
|
||||
getCredential: <T extends PlatformCredentials>(platform: Platform) =>
|
||||
getCredential<T>(id, platform),
|
||||
};
|
||||
|
||||
// Cache only the serialisable fields (not the function)
|
||||
await redis.setEx(`customer:apikey:${apiKey}`, 60, JSON.stringify({ id, plan, active, email }));
|
||||
return customer;
|
||||
const row = rows[0];
|
||||
await redis.setEx(`customer:apikey:${apiKey}`, 60, JSON.stringify(row));
|
||||
return buildCustomer(row);
|
||||
}
|
||||
|
||||
// Express middleware: resolve API key → Customer and attach to req.customer
|
||||
export async function resolveCustomerById(id: string): Promise<Customer | null> {
|
||||
const cached = await redis.get(`customer:id:${id}`);
|
||||
if (cached) {
|
||||
const base = JSON.parse(cached) as CustomerRow;
|
||||
return buildCustomer(base);
|
||||
}
|
||||
|
||||
const [rows] = await getPool().query<CustomerRow[]>(
|
||||
'SELECT id, plan, active, email FROM customers WHERE id = ?',
|
||||
[id]
|
||||
);
|
||||
if (!rows.length) return null;
|
||||
|
||||
const row = rows[0];
|
||||
await redis.setEx(`customer:id:${id}`, 60, JSON.stringify(row));
|
||||
return buildCustomer(row);
|
||||
}
|
||||
|
||||
// Express middleware: resolve API key OR JWT cookie → Customer and attach to req.customer
|
||||
export async function meterMiddleware(
|
||||
req: Request,
|
||||
res: Response,
|
||||
next: NextFunction
|
||||
): Promise<void> {
|
||||
try {
|
||||
// 1. Try API key
|
||||
const apiKey =
|
||||
(req.headers['x-api-key'] as string | undefined) ||
|
||||
(req.query.key as string | undefined);
|
||||
|
||||
if (!apiKey) {
|
||||
res.status(401).json({ error: 'Missing API key' });
|
||||
if (apiKey) {
|
||||
const customer = await resolveCustomerByApiKey(apiKey);
|
||||
if (!customer) {
|
||||
res.status(401).json({ error: 'Invalid API key' });
|
||||
return;
|
||||
}
|
||||
if (!customer.active) {
|
||||
res.status(403).json({ error: 'Account suspended' });
|
||||
return;
|
||||
}
|
||||
(req as Request & { customer: Customer }).customer = customer;
|
||||
next();
|
||||
return;
|
||||
}
|
||||
|
||||
const customer = await resolveCustomer(apiKey);
|
||||
if (!customer) {
|
||||
res.status(401).json({ error: 'Invalid API key' });
|
||||
return;
|
||||
// 2. Try JWT session cookie
|
||||
const jwtCookie = req.cookies?.session;
|
||||
if (jwtCookie) {
|
||||
try {
|
||||
const payload = verifyJWT(jwtCookie);
|
||||
const customer = await resolveCustomerById(payload.sub);
|
||||
if (!customer) {
|
||||
res.status(401).json({ error: 'Invalid session' });
|
||||
return;
|
||||
}
|
||||
if (!customer.active) {
|
||||
res.status(403).json({ error: 'Account suspended' });
|
||||
return;
|
||||
}
|
||||
(req as Request & { customer: Customer }).customer = customer;
|
||||
next();
|
||||
return;
|
||||
} catch {
|
||||
res.status(401).json({ error: 'Invalid or expired session' });
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
if (!customer.active) {
|
||||
res.status(403).json({ error: 'Account suspended' });
|
||||
return;
|
||||
}
|
||||
|
||||
(req as Request & { customer: Customer }).customer = customer;
|
||||
next();
|
||||
res.status(401).json({ error: 'Missing API key or session' });
|
||||
} catch (err) {
|
||||
next(err);
|
||||
}
|
||||
|
||||
48
src/billing/usage.ts
Normal file
48
src/billing/usage.ts
Normal file
@@ -0,0 +1,48 @@
|
||||
import { getPool } from '../db.js';
|
||||
import { PLANS, type PlanKey } from './plans.js';
|
||||
|
||||
export async function recordUsage(
|
||||
customerId: string,
|
||||
platform: string,
|
||||
action: string
|
||||
): Promise<void> {
|
||||
await getPool().query(
|
||||
'INSERT INTO usage_logs (customer_id, platform, action) VALUES (?, ?, ?)',
|
||||
[customerId, platform, action]
|
||||
);
|
||||
}
|
||||
|
||||
export async function getMonthlyUsage(customerId: string): Promise<number> {
|
||||
const [rows] = await getPool().query<any[]>(
|
||||
`SELECT 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')`,
|
||||
[customerId]
|
||||
);
|
||||
return rows[0]?.count ?? 0;
|
||||
}
|
||||
|
||||
export async function getUsageBreakdown(customerId: string): Promise<Record<string, number>> {
|
||||
const [rows] = await getPool().query<any[]>(
|
||||
`SELECT platform, COUNT(*) as count FROM usage_logs
|
||||
WHERE customer_id = ?
|
||||
AND created_at >= DATE_FORMAT(NOW(), '%Y-%m-01')
|
||||
GROUP BY platform`,
|
||||
[customerId]
|
||||
);
|
||||
const breakdown: Record<string, number> = {};
|
||||
for (const row of rows) {
|
||||
breakdown[row.platform] = row.count;
|
||||
}
|
||||
return breakdown;
|
||||
}
|
||||
|
||||
export async function checkLimit(customerId: string, plan: PlanKey): Promise<{ allowed: boolean; limit: number; used: number }> {
|
||||
const planConfig = PLANS[plan];
|
||||
if (planConfig.monthlyCallLimit === -1) {
|
||||
return { allowed: true, limit: -1, used: 0 };
|
||||
}
|
||||
const used = await getMonthlyUsage(customerId);
|
||||
return { allowed: used < planConfig.monthlyCallLimit, limit: planConfig.monthlyCallLimit, used };
|
||||
}
|
||||
Reference in New Issue
Block a user