master-zapier-plan-draft/packages/zappier/src/admin.ts
George Lambert c32b65038a
Some checks are pending
offline / test (push) Waiting to run
Turn lab IAM on, move portal to access-web, harden sessions and receipts.
Fleet and department doors now check STAFF_IAM_URL. Walkthrough: cs can
credit (agent is the IAM user) and cannot export; operator can fleet
POST; admin /me is owner. Portal is the public web door at :3021/portal/.
IAM sessions persist; login is rate-limited per user; receipt PDF is
branded. lan-134 stays disabled.
2026-09-11 18:59:18 -04:00

676 lines
24 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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';
import { CreditLedger } from './credits';
import { invoicesToAccountingCsv, invoicesToQuickBooksIif } from './accounting-export';
import { composeStatement } from './statement';
import { BILLING_SUBJECTS, natsPublish } from './billing-nats';
import { booksConfigured, ledgerAdjust, ledgerPutCustomer, ledgerStatement } from './ledger';
import { allows, iamLogin, iamUrl, permForAdminPath, StaffSession } from './staff-iam';
// Issued login tokens (in-memory; a restart simply requires logging in again).
const sessions = new Map<string, StaffSession>();
declare module 'express-serve-static-core' {
interface Request {
staff?: StaffSession;
}
}
export function adminLoginRouter(users: AdminUserRepo): Router {
const router = Router();
router.post('/login', async (req, res) => {
const { username, password } = req.body ?? {};
if (typeof username !== 'string' || typeof password !== 'string') {
res.status(401).json({ error: 'invalid username or password' });
return;
}
if (iamUrl()) {
const via = await iamLogin(username, password);
if (!via) {
res.status(401).json({ error: 'invalid username or password' });
return;
}
sessions.set(via.token, via.user);
res.json({ token: via.token, user: via.user });
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, { username, permissions: ['*'], t: Date.now() });
res.json({ token, user: { username, permissions: ['*'] } });
});
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) {
req.staff = { username: 'x-admin-key', permissions: ['*'], t: Date.now() };
return next();
}
const bearer = req.header('authorization');
const token = bearer?.startsWith('Bearer ') ? bearer.slice(7) : undefined;
const sess = token ? sessions.get(token) : undefined;
if (sess) {
req.staff = sess;
return next();
}
const base = iamUrl();
if (base && token) {
void (async () => {
try {
const r = await fetch(`${base}/check`, { headers: { authorization: `Bearer ${token}` } });
if (!r.ok) {
res.status(403).json({ error: 'invalid or missing admin key' });
return;
}
const body = (await r.json()) as { user?: StaffSession };
if (body.user) {
req.staff = {
username: body.user.username,
name: body.user.name,
roles: body.user.roles,
permissions: body.user.permissions || [],
t: Date.now(),
};
return next();
}
} catch {
/* fall through */
}
res.status(403).json({ error: 'invalid or missing admin key' });
})();
return;
}
res.status(403).json({ error: 'invalid or missing admin key' });
};
}
export function adminPerms(): RequestHandler {
return (req, res, next) => {
if (!iamUrl()) return next();
const need = permForAdminPath(req.method, req.path);
if (!allows(req.staff?.permissions, need)) {
res.status(403).json({ error: 'forbidden', permission: need });
return;
}
next();
};
}
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, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;');
}
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,
credits: CreditLedger = new CreditLedger(),
): Router {
const router = Router();
router.get('/me', (req, res) => {
res.json({
username: req.staff?.username,
name: req.staff?.name,
roles: req.staff?.roles || [],
permissions: req.staff?.permissions || ['*'],
iam: Boolean(iamUrl()),
iamUrl: iamUrl() || undefined,
});
});
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, passwordHash: undefined, totpSecret: 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);
void ledgerPutCustomer({ customerId: customer.id, name: customer.name });
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;
}
const next = {
...existing,
...(name !== undefined ? { name } : {}),
...(tierId !== undefined ? { tierId } : {}),
...(multiplierOverride !== undefined ? { multiplierOverride } : {}),
...(stripeCustomerId !== undefined ? { stripeCustomerId } : {}),
...(billingType !== undefined ? { billingType } : {}),
...(email !== undefined ? { email } : {}),
};
customers.save(next);
void ledgerPutCustomer({ customerId: next.id, name: next.name, veraeUserId: next.veraeUserId });
res.json({ ok: true });
});
/* ---------------- customer service: credits ---------------- */
router.post('/credits', async (req, res) => {
const { customerId, cents, reason, agent } = req.body ?? {};
if (typeof customerId !== 'string' || !Number.isFinite(Number(cents))) {
res.status(400).json({ error: 'customerId and cents required' });
return;
}
const customer = customers.list().find((c) => c.id === customerId);
if (!customer) {
res.status(404).json({ error: 'customer not found' });
return;
}
const delta = Math.trunc(Number(cents));
const rec = credits.add({
customerId,
cents: delta,
reason: typeof reason === 'string' ? reason : 'credit adjustment',
agent: typeof agent === 'string' ? agent : 'admin',
});
const row = await ledgerAdjust(
{
customerId,
veraeUserId: customer.veraeUserId,
cents: delta,
reason: rec.reason,
agent: rec.agent,
kind: 'credit',
},
'staff',
);
let prepaid = customer.balanceCents ?? 0;
if (row && typeof row.prepaidCents === 'number') prepaid = row.prepaidCents;
else if (!booksConfigured()) prepaid += delta;
customers.save({ ...customer, balanceCents: prepaid });
natsPublish(BILLING_SUBJECTS.CREDIT_APPLIED, { ...rec, veraeUserId: customer.veraeUserId }, 'staff');
res.status(201).json({ ...rec, prepaidCents: prepaid });
});
router.get('/credits', (req, res) => {
const customerId = typeof req.query.customerId === 'string' ? req.query.customerId : undefined;
res.json({ credits: credits.list(customerId) });
});
router.get('/statement/:id', async (req, res) => {
const customer = customers.list().find((c) => c.id === req.params.id);
if (!customer) {
res.status(404).json({ error: 'customer not found' });
return;
}
const fromBooks = await ledgerStatement(customer.id, 'staff', customer.veraeUserId);
if (fromBooks) {
res.json({ ...fromBooks, name: customer.name, tierId: customer.tierId, source: fromBooks.source || 'account-balance' });
return;
}
res.json({
...composeStatement({
customerId: customer.id,
name: customer.name,
veraeUserId: customer.veraeUserId,
prepaidCents: customer.balanceCents ?? 0,
credits: credits.list(customer.id),
usage: accounting.usage.listFor(customer.id),
invoices: accounting.invoices.list({ customerId: customer.id }),
}),
name: customer.name,
tierId: customer.tierId,
source: 'local',
});
});
router.get('/sales/quote/:id', (req, res) => {
const customer = customers.list().find((c) => c.id === req.params.id);
if (!customer) {
res.status(404).json({ error: 'customer not found' });
return;
}
const tier = store.getTiers().find((t) => t.id === customer.tierId);
res.json({
customerId: customer.id,
name: customer.name,
tierId: customer.tierId,
multiplierOverride: customer.multiplierOverride ?? null,
monthlyCreditCents: tier?.monthlyCreditCents ?? 0,
listMultiplier: tier?.multiplier ?? 1,
});
});
router.get('/exports/quickbooks.iif', (req, res) => {
const period = typeof req.query.period === 'string' ? req.query.period : undefined;
const invoices = accounting.invoices.list({ ...(period ? { period } : {}) });
res
.type('text/plain')
.set('content-disposition', 'attachment; filename="zappier-quickbooks.iif"')
.send(invoicesToQuickBooksIif(invoices));
});
router.get('/exports/accounting.csv', (req, res) => {
const period = typeof req.query.period === 'string' ? req.query.period : undefined;
const invoices = accounting.invoices.list({ ...(period ? { period } : {}) });
res
.type('text/csv')
.set('content-disposition', 'attachment; filename="zappier-accounting.csv"')
.send(invoicesToAccountingCsv(invoices));
});
/* ---------------- 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;
}