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:
Garfield
2026-05-13 08:42:33 -04:00
parent 7796de12bf
commit a5e4c55885
46 changed files with 4054 additions and 171 deletions

122
src/billing/invoices.ts Normal file
View 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)
);
}

View File

@@ -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
View 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 };
}