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(); 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; 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; 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, '"'); } const dollars = (cents: number): string => `$${(cents / 100).toFixed(2)}`; export function renderInvoiceHtml(invoice: Invoice, customerName: string): string { const rows = invoice.lines .map( (l) => `${escapeHtml(l.endpointId)}${l.calls}${dollars(l.cents)}`, ) .join(''); const po = invoice.poNumber ? `

Purchase order: ${escapeHtml(invoice.poNumber)}

` : ''; const due = invoice.dueAtMs ? new Date(invoice.dueAtMs).toISOString().slice(0, 10) : '—'; return `${invoice.id}

Invoice ${escapeHtml(invoice.id)}

Zappier API usage · period ${escapeHtml(invoice.period)} · due ${due}

${invoice.status.toUpperCase()}

Billed to: ${escapeHtml(customerName)} (${escapeHtml(invoice.customerId)})
Billing type: ${escapeHtml(invoice.billingType)}

${po} ${rows}
EndpointCallsAmount
Usage total${dollars(invoice.totalCents)}
Monthly credit−${dollars(invoice.creditCents)}
Amount due${dollars(invoice.billableCents)}
`; } 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; }