Some checks are pending
offline / test (push) Waiting to run
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.
676 lines
24 KiB
TypeScript
676 lines
24 KiB
TypeScript
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, '&')
|
||
.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,
|
||
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;
|
||
}
|