Milestone 0: import zappier billing, Verae middleware, and Zapier research
Compose-ready workspace: packages/zappier (rate card, portal, Stripe), packages/verae-zapier-middleware (timestamp + NATS), packages/verae-zapier (CLI app), vendor/zapier-platform, and research/zapier vendor corpus. Gate 0 structure checks pass. Product code and research are not yet wired.
This commit is contained in:
commit
b4150c8250
1364 changed files with 6814366 additions and 0 deletions
485
packages/zappier/src/admin.ts
Normal file
485
packages/zappier/src/admin.ts
Normal file
|
|
@ -0,0 +1,485 @@
|
|||
import { randomBytes, randomUUID } from 'crypto';
|
||||
import fs from 'fs';
|
||||
import path from 'path';
|
||||
import { RequestHandler, Router } from 'express';
|
||||
import { verifyPassword } from './accounts';
|
||||
import { AdminUserRepo, makeAdminUser } from './admin-users';
|
||||
import { CustomerRepo } from './auth';
|
||||
import { buildInvoice, Invoice, InvoiceRepo } from './invoicing';
|
||||
import { PriceRule, PricingStore, TierConfig } from './pricing';
|
||||
import { PROJECT_ROOT } from './paths';
|
||||
import { billingRows, toCsv, usageTrend } from './reports';
|
||||
import { UsageRepo } from './usage';
|
||||
|
||||
// Issued login tokens (in-memory; a restart simply requires logging in again).
|
||||
const sessions = new Map<string, number>();
|
||||
|
||||
export function adminLoginRouter(users: AdminUserRepo): Router {
|
||||
const router = Router();
|
||||
router.post('/login', (req, res) => {
|
||||
const { username, password } = req.body ?? {};
|
||||
if (typeof username !== 'string' || typeof password !== 'string') {
|
||||
res.status(401).json({ error: 'invalid username or password' });
|
||||
return;
|
||||
}
|
||||
const user = users.findByUsername(username);
|
||||
if (!user || !user.active || !verifyPassword(password, user.passwordHash)) {
|
||||
res.status(401).json({ error: 'invalid username or password' });
|
||||
return;
|
||||
}
|
||||
const token = randomBytes(24).toString('hex');
|
||||
sessions.set(token, Date.now());
|
||||
res.json({ token });
|
||||
});
|
||||
return router;
|
||||
}
|
||||
|
||||
export function adminAuth(): RequestHandler {
|
||||
return (req, res, next) => {
|
||||
const expected = process.env.ADMIN_KEY ?? 'admin-dev-key';
|
||||
if (req.header('x-admin-key') === expected) return next();
|
||||
const bearer = req.header('authorization');
|
||||
const token = bearer?.startsWith('Bearer ') ? bearer.slice(7) : undefined;
|
||||
if (token && sessions.has(token)) return next();
|
||||
res.status(403).json({ error: 'invalid or missing admin key' });
|
||||
};
|
||||
}
|
||||
|
||||
function isValidRule(rule: unknown): rule is PriceRule {
|
||||
if (!rule || typeof rule !== 'object') return false;
|
||||
const r = rule as Record<string, unknown>;
|
||||
if (r.kind === 'free') return true;
|
||||
if (r.kind === 'fixed') return typeof r.fixedCents === 'number';
|
||||
if (r.kind === 'variable') {
|
||||
return ['baseCents', 'perKbCents', 'perMbCents'].every((k) => typeof r[k] === 'number');
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
function isValidTier(tier: unknown): tier is TierConfig {
|
||||
if (!tier || typeof tier !== 'object') return false;
|
||||
const t = tier as Record<string, unknown>;
|
||||
return (
|
||||
typeof t.name === 'string' &&
|
||||
typeof t.multiplier === 'number' &&
|
||||
typeof t.monthlyCreditCents === 'number' &&
|
||||
(t.defaultRule === undefined || isValidRule(t.defaultRule))
|
||||
);
|
||||
}
|
||||
|
||||
export interface AccountingDeps {
|
||||
usage: UsageRepo;
|
||||
invoices: InvoiceRepo;
|
||||
users: AdminUserRepo;
|
||||
}
|
||||
|
||||
const PERIOD_RE = /^\d{4}-(0[1-9]|1[0-2])$/;
|
||||
|
||||
function periodWindow(period: string): { from: Date; to: Date } {
|
||||
const [y, m] = period.split('-').map(Number);
|
||||
return { from: new Date(Date.UTC(y, m - 1, 1)), to: new Date(Date.UTC(y, m, 1)) };
|
||||
}
|
||||
|
||||
function parseDate(value: unknown): Date | undefined {
|
||||
if (typeof value !== 'string' || value.trim() === '') return undefined;
|
||||
const d = new Date(value);
|
||||
return Number.isNaN(d.getTime()) ? undefined : d;
|
||||
}
|
||||
|
||||
function escapeHtml(s: string): string {
|
||||
return s
|
||||
.replace(/&/g, '&')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>')
|
||||
.replace(/"/g, '"');
|
||||
}
|
||||
|
||||
const dollars = (cents: number): string => `$${(cents / 100).toFixed(2)}`;
|
||||
|
||||
export function renderInvoiceHtml(invoice: Invoice, customerName: string): string {
|
||||
const rows = invoice.lines
|
||||
.map(
|
||||
(l) =>
|
||||
`<tr><td>${escapeHtml(l.endpointId)}</td><td>${l.calls}</td><td>${dollars(l.cents)}</td></tr>`,
|
||||
)
|
||||
.join('');
|
||||
const po = invoice.poNumber
|
||||
? `<p><strong>Purchase order:</strong> ${escapeHtml(invoice.poNumber)}</p>`
|
||||
: '';
|
||||
const due = invoice.dueAtMs ? new Date(invoice.dueAtMs).toISOString().slice(0, 10) : '—';
|
||||
return `<!doctype html><html><head><meta charset="utf-8"><title>${invoice.id}</title>
|
||||
<style>
|
||||
body { font-family: -apple-system, "Segoe UI", sans-serif; max-width: 720px; margin: 2rem auto; color: #171a26; }
|
||||
h1 { font-size: 1.4rem; margin-bottom: 0; }
|
||||
.muted { color: #6b7186; }
|
||||
table { border-collapse: collapse; width: 100%; margin: 1.5rem 0; }
|
||||
th, td { border-bottom: 1px solid #e5e7f0; text-align: left; padding: 0.5rem 0.6rem; }
|
||||
th { font-size: 0.75rem; text-transform: uppercase; color: #6b7186; }
|
||||
.totals td { border: 0; padding: 0.2rem 0.6rem; }
|
||||
.totals .grand { font-weight: 700; font-size: 1.1rem; border-top: 2px solid #171a26; }
|
||||
.status { display: inline-block; padding: 0.15rem 0.7rem; border-radius: 999px; background: #eef0fe; color: #4f46e5; font-weight: 700; font-size: 0.8rem; }
|
||||
@media print { body { margin: 0; } }
|
||||
</style></head><body>
|
||||
<h1>Invoice ${escapeHtml(invoice.id)}</h1>
|
||||
<p class="muted">Zappier API usage · period ${escapeHtml(invoice.period)} · due ${due}</p>
|
||||
<p><span class="status">${invoice.status.toUpperCase()}</span></p>
|
||||
<p><strong>Billed to:</strong> ${escapeHtml(customerName)} (${escapeHtml(invoice.customerId)})<br>
|
||||
<strong>Billing type:</strong> ${escapeHtml(invoice.billingType)}</p>
|
||||
${po}
|
||||
<table><thead><tr><th>Endpoint</th><th>Calls</th><th>Amount</th></tr></thead><tbody>${rows}</tbody></table>
|
||||
<table class="totals">
|
||||
<tr><td>Usage total</td><td>${dollars(invoice.totalCents)}</td></tr>
|
||||
<tr><td>Monthly credit</td><td>−${dollars(invoice.creditCents)}</td></tr>
|
||||
<tr class="grand"><td>Amount due</td><td>${dollars(invoice.billableCents)}</td></tr>
|
||||
</table>
|
||||
</body></html>`;
|
||||
}
|
||||
|
||||
export function adminRouter(
|
||||
store: PricingStore,
|
||||
customers: CustomerRepo,
|
||||
accounting: AccountingDeps,
|
||||
): Router {
|
||||
const router = Router();
|
||||
|
||||
router.get('/pricing', (req, res) => {
|
||||
res.json({ rateCard: store.getRateCard(), tiers: store.getTiers() });
|
||||
});
|
||||
|
||||
router.put('/endpoints/:id', (req, res) => {
|
||||
if (!isValidRule(req.body)) {
|
||||
res.status(400).json({ error: 'invalid price rule' });
|
||||
return;
|
||||
}
|
||||
store.upsertEndpoint(req.params.id, req.body);
|
||||
res.json({ ok: true });
|
||||
});
|
||||
|
||||
router.delete('/endpoints/:id', (req, res) => {
|
||||
store.deleteEndpoint(req.params.id);
|
||||
res.json({ ok: true });
|
||||
});
|
||||
|
||||
router.put('/tiers/:id', (req, res) => {
|
||||
if (!isValidTier(req.body)) {
|
||||
res.status(400).json({ error: 'invalid tier config' });
|
||||
return;
|
||||
}
|
||||
store.upsertTier({ ...req.body, id: req.params.id });
|
||||
res.json({ ok: true });
|
||||
});
|
||||
|
||||
router.delete('/tiers/:id', (req, res) => {
|
||||
store.deleteTier(req.params.id);
|
||||
res.json({ ok: true });
|
||||
});
|
||||
|
||||
router.get('/customers', (req, res) => {
|
||||
res.json({ customers: customers.list().map((c) => ({ ...c, apiKey: undefined })) });
|
||||
});
|
||||
|
||||
router.post('/customers', (req, res) => {
|
||||
const { name, tierId } = req.body ?? {};
|
||||
if (typeof name !== 'string' || typeof tierId !== 'string') {
|
||||
res.status(400).json({ error: 'name and tierId required' });
|
||||
return;
|
||||
}
|
||||
const customer = {
|
||||
id: `cust_${randomUUID().slice(0, 8)}`,
|
||||
name,
|
||||
tierId,
|
||||
apiKey: `key-${randomBytes(12).toString('hex')}`,
|
||||
};
|
||||
customers.save(customer);
|
||||
res.status(201).json(customer);
|
||||
});
|
||||
|
||||
router.put('/customers/:id', (req, res) => {
|
||||
const existing = customers.list().find((c) => c.id === req.params.id);
|
||||
if (!existing) {
|
||||
res.status(404).json({ error: 'customer not found' });
|
||||
return;
|
||||
}
|
||||
const { name, tierId, multiplierOverride, stripeCustomerId, billingType, email } =
|
||||
req.body ?? {};
|
||||
if (
|
||||
billingType !== undefined &&
|
||||
billingType !== 'stripe' &&
|
||||
billingType !== 'purchase_order'
|
||||
) {
|
||||
res.status(400).json({ error: 'billingType must be stripe or purchase_order' });
|
||||
return;
|
||||
}
|
||||
customers.save({
|
||||
...existing,
|
||||
...(name !== undefined ? { name } : {}),
|
||||
...(tierId !== undefined ? { tierId } : {}),
|
||||
...(multiplierOverride !== undefined ? { multiplierOverride } : {}),
|
||||
...(stripeCustomerId !== undefined ? { stripeCustomerId } : {}),
|
||||
...(billingType !== undefined ? { billingType } : {}),
|
||||
...(email !== undefined ? { email } : {}),
|
||||
});
|
||||
res.json({ ok: true });
|
||||
});
|
||||
|
||||
/* ---------------- accounting: invoices ---------------- */
|
||||
|
||||
router.post('/invoices/generate', (req, res) => {
|
||||
const { period, customerId, poNumber } = req.body ?? {};
|
||||
if (typeof period !== 'string' || !PERIOD_RE.test(period)) {
|
||||
res.status(400).json({ error: 'period must be YYYY-MM' });
|
||||
return;
|
||||
}
|
||||
const { from, to } = periodWindow(period);
|
||||
const tiers = store.getTiers();
|
||||
const generated: string[] = [];
|
||||
const skipped: { customerId: string; reason: string }[] = [];
|
||||
|
||||
for (const customer of customers.list()) {
|
||||
if (customerId && customer.id !== customerId) continue;
|
||||
const existing = accounting.invoices
|
||||
.list({ customerId: customer.id, period })
|
||||
.find((i) => i.status !== 'draft');
|
||||
if (existing) {
|
||||
skipped.push({ customerId: customer.id, reason: `${existing.id} already ${existing.status}` });
|
||||
continue;
|
||||
}
|
||||
const entries = accounting.usage
|
||||
.listFor(customer.id, from)
|
||||
.filter((e) => e.timestamp < to);
|
||||
if (entries.length === 0) {
|
||||
skipped.push({ customerId: customer.id, reason: 'no usage in period' });
|
||||
continue;
|
||||
}
|
||||
const tier = tiers.find((t) => t.id === customer.tierId);
|
||||
if (!tier) {
|
||||
skipped.push({ customerId: customer.id, reason: `unknown tier ${customer.tierId}` });
|
||||
continue;
|
||||
}
|
||||
const draft = accounting.invoices.list({ customerId: customer.id, period })[0];
|
||||
const invoice = buildInvoice({
|
||||
customer,
|
||||
period,
|
||||
sequence: draft ? Number(draft.id.slice(-4)) : accounting.invoices.nextSequence(period),
|
||||
entries,
|
||||
tier,
|
||||
...(poNumber !== undefined ? { poNumber } : draft?.poNumber !== undefined ? { poNumber: draft.poNumber } : {}),
|
||||
});
|
||||
if (draft) invoice.id = draft.id;
|
||||
accounting.invoices.save(invoice);
|
||||
generated.push(invoice.id);
|
||||
}
|
||||
res.json({ generated, skipped });
|
||||
});
|
||||
|
||||
router.get('/invoices', (req, res) => {
|
||||
const { customerId, period, status } = req.query;
|
||||
res.json({
|
||||
invoices: accounting.invoices.list({
|
||||
...(typeof customerId === 'string' ? { customerId } : {}),
|
||||
...(typeof period === 'string' ? { period } : {}),
|
||||
...(typeof status === 'string' ? { status: status as Invoice['status'] } : {}),
|
||||
}),
|
||||
});
|
||||
});
|
||||
|
||||
router.get('/invoices/:id', (req, res) => {
|
||||
const invoice = accounting.invoices.get(req.params.id);
|
||||
if (!invoice) {
|
||||
res.status(404).json({ error: 'invoice not found' });
|
||||
return;
|
||||
}
|
||||
if (req.query.format === 'html') {
|
||||
const name = customers.list().find((c) => c.id === invoice.customerId)?.name ?? invoice.customerId;
|
||||
res.type('html').send(renderInvoiceHtml(invoice, name));
|
||||
return;
|
||||
}
|
||||
res.json(invoice);
|
||||
});
|
||||
|
||||
router.post('/invoices/:id/issue', (req, res) => {
|
||||
const invoice = accounting.invoices.get(req.params.id);
|
||||
if (!invoice) {
|
||||
res.status(404).json({ error: 'invoice not found' });
|
||||
return;
|
||||
}
|
||||
if (invoice.status !== 'draft') {
|
||||
res.status(409).json({ error: `invoice is ${invoice.status}, not draft` });
|
||||
return;
|
||||
}
|
||||
const now = Date.now();
|
||||
const issued: Invoice = {
|
||||
...invoice,
|
||||
status: 'issued',
|
||||
issuedAtMs: now,
|
||||
...(invoice.billingType === 'purchase_order'
|
||||
? { dueAtMs: now + 30 * 24 * 60 * 60 * 1000 }
|
||||
: {}),
|
||||
};
|
||||
// Prepaid drawdown: a balance that fully covers the billable amount is
|
||||
// deducted and the invoice goes straight to paid. Partial coverage stays
|
||||
// untouched (no partial payments).
|
||||
const customer = customers.list().find((c) => c.id === invoice.customerId);
|
||||
const balance = customer?.balanceCents ?? 0;
|
||||
if (customer && invoice.billableCents > 0 && balance >= invoice.billableCents) {
|
||||
customers.save({ ...customer, balanceCents: balance - invoice.billableCents });
|
||||
const paid: Invoice = { ...issued, status: 'paid', paidAtMs: now };
|
||||
accounting.invoices.save(paid);
|
||||
res.json(paid);
|
||||
return;
|
||||
}
|
||||
accounting.invoices.save(issued);
|
||||
res.json(issued);
|
||||
});
|
||||
|
||||
router.post('/invoices/:id/paid', (req, res) => {
|
||||
const invoice = accounting.invoices.get(req.params.id);
|
||||
if (!invoice) {
|
||||
res.status(404).json({ error: 'invoice not found' });
|
||||
return;
|
||||
}
|
||||
if (invoice.status !== 'issued') {
|
||||
res.status(409).json({ error: `invoice is ${invoice.status}, not issued` });
|
||||
return;
|
||||
}
|
||||
const paid: Invoice = { ...invoice, status: 'paid', paidAtMs: Date.now() };
|
||||
accounting.invoices.save(paid);
|
||||
res.json(paid);
|
||||
});
|
||||
|
||||
/* ---------------- accounting: reports ---------------- */
|
||||
|
||||
router.get('/reports/billing', (req, res) => {
|
||||
const all = customers.list().flatMap((c) => accounting.usage.listFor(c.id));
|
||||
const rows = billingRows({
|
||||
entries: all,
|
||||
customers: customers.list(),
|
||||
tiers: store.getTiers(),
|
||||
...(parseDate(req.query.from) ? { from: parseDate(req.query.from)! } : {}),
|
||||
...(parseDate(req.query.to) ? { to: parseDate(req.query.to)! } : {}),
|
||||
...(typeof req.query.customerId === 'string' ? { customerId: req.query.customerId } : {}),
|
||||
...(typeof req.query.billingType === 'string'
|
||||
? { billingType: req.query.billingType as 'stripe' | 'purchase_order' }
|
||||
: {}),
|
||||
});
|
||||
if (req.query.format === 'csv') {
|
||||
const csv = toCsv(rows, [
|
||||
{ key: 'customerId', label: 'Customer Id' },
|
||||
{ key: 'name', label: 'Name' },
|
||||
{ key: 'billingType', label: 'Billing Type' },
|
||||
{ key: 'calls', label: 'Calls' },
|
||||
{ key: 'totalCents', label: 'Total Cents' },
|
||||
{ key: 'creditCents', label: 'Credit Cents' },
|
||||
{ key: 'billableCents', label: 'Billable Cents' },
|
||||
]);
|
||||
res
|
||||
.type('text/csv')
|
||||
.set('content-disposition', 'attachment; filename="billing-report.csv"')
|
||||
.send(csv);
|
||||
return;
|
||||
}
|
||||
res.json({ rows });
|
||||
});
|
||||
|
||||
router.get('/reports/usage-trend', (req, res) => {
|
||||
const from = parseDate(req.query.from);
|
||||
const to = parseDate(req.query.to);
|
||||
const customerId = typeof req.query.customerId === 'string' ? req.query.customerId : undefined;
|
||||
const entries = customers
|
||||
.list()
|
||||
.filter((c) => (customerId ? c.id === customerId : true))
|
||||
.flatMap((c) => accounting.usage.listFor(c.id))
|
||||
.filter((e) => (!from || e.timestamp >= from) && (!to || e.timestamp < to));
|
||||
const bucket = req.query.bucket === 'week' ? 'week' : 'day';
|
||||
res.json({ points: usageTrend(entries, bucket) });
|
||||
});
|
||||
|
||||
/* ---------------- admin user management ---------------- */
|
||||
|
||||
const USERNAME_RE = /^[a-zA-Z0-9_.-]+$/;
|
||||
|
||||
router.get('/users', (req, res) => {
|
||||
res.json({
|
||||
users: accounting.users.list().map((u) => ({
|
||||
id: u.id,
|
||||
username: u.username,
|
||||
active: u.active,
|
||||
createdMs: u.createdMs,
|
||||
})),
|
||||
});
|
||||
});
|
||||
|
||||
router.post('/users', (req, res) => {
|
||||
const { username, password } = req.body ?? {};
|
||||
if (typeof username !== 'string' || !USERNAME_RE.test(username)) {
|
||||
res.status(400).json({ error: 'username must match [a-zA-Z0-9_.-]+' });
|
||||
return;
|
||||
}
|
||||
if (typeof password !== 'string' || password.length < 8) {
|
||||
res.status(400).json({ error: 'password must be at least 8 characters' });
|
||||
return;
|
||||
}
|
||||
if (accounting.users.findByUsername(username)) {
|
||||
res.status(409).json({ error: 'username already exists' });
|
||||
return;
|
||||
}
|
||||
const user = makeAdminUser(username, password);
|
||||
accounting.users.save(user);
|
||||
res.status(201).json({
|
||||
id: user.id,
|
||||
username: user.username,
|
||||
active: user.active,
|
||||
createdMs: user.createdMs,
|
||||
});
|
||||
});
|
||||
|
||||
const setActive = (active: boolean): RequestHandler => (req, res) => {
|
||||
const user = accounting.users.findByUsername(req.params.username);
|
||||
if (!user) {
|
||||
res.status(404).json({ error: 'admin user not found' });
|
||||
return;
|
||||
}
|
||||
if (!active) {
|
||||
const activeCount = accounting.users.list().filter((u) => u.active).length;
|
||||
if (user.active && activeCount <= 1) {
|
||||
res.status(400).json({ error: 'cannot deactivate the last active admin user' });
|
||||
return;
|
||||
}
|
||||
}
|
||||
accounting.users.save({ ...user, active });
|
||||
res.json({ ok: true });
|
||||
};
|
||||
|
||||
router.post('/users/:username/deactivate', setActive(false));
|
||||
router.post('/users/:username/activate', setActive(true));
|
||||
|
||||
/* ---------------- system: Zapier integration status ---------------- */
|
||||
|
||||
router.get('/zapier/status', (req, res) => {
|
||||
const dir = path.join(PROJECT_ROOT, 'zapier-app');
|
||||
const readDir = (sub: string): string[] => {
|
||||
try {
|
||||
return fs
|
||||
.readdirSync(path.join(dir, sub))
|
||||
.filter((f) => f.endsWith('.js'))
|
||||
.map((f) => f.replace(/\.js$/, ''));
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
};
|
||||
let version: string | undefined;
|
||||
try {
|
||||
version = JSON.parse(fs.readFileSync(path.join(dir, 'package.json'), 'utf8')).version;
|
||||
} catch {
|
||||
version = undefined;
|
||||
}
|
||||
res.json({
|
||||
appDirPresent: fs.existsSync(dir),
|
||||
version,
|
||||
triggers: readDir('triggers'),
|
||||
creates: readDir('creates'),
|
||||
});
|
||||
});
|
||||
|
||||
return router;
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue