const state = { pricing: null, customers: [], invoices: [], report: null, trend: null, system: null, users: [] };
const TOKEN_KEY = 'zappier-admin-token';
/* ---------------- auth ---------------- */
function token() {
return localStorage.getItem(TOKEN_KEY);
}
function showLogin(message = '') {
document.getElementById('shell').classList.remove('on');
document.getElementById('login').style.display = 'grid';
document.getElementById('login-error').textContent = message;
}
function showShell() {
document.getElementById('login').style.display = 'none';
document.getElementById('shell').classList.add('on');
}
document.getElementById('login-form').addEventListener('submit', async (e) => {
e.preventDefault();
const username = document.getElementById('login-username').value.trim();
const password = document.getElementById('login-password').value;
try {
const res = await fetch('/admin/api/login', {
method: 'POST',
headers: { 'content-type': 'application/json' },
body: JSON.stringify({ username, password }),
});
if (!res.ok) throw new Error((await res.json()).error || 'Login failed');
const { token: t } = await res.json();
localStorage.setItem(TOKEN_KEY, t);
showShell();
load().catch((err) => say(err.message, true));
} catch (err) {
showLogin(err.message);
}
});
document.getElementById('logout').addEventListener('click', () => {
localStorage.removeItem(TOKEN_KEY);
location.reload();
});
/* ---------------- api + status ---------------- */
async function api(path, options = {}) {
const res = await fetch(`/admin/api${path}`, {
...options,
headers: { 'content-type': 'application/json', authorization: `Bearer ${token()}` },
});
if (res.status === 403) {
localStorage.removeItem(TOKEN_KEY);
showLogin('Session expired — sign in again.');
throw new Error('Session expired.');
}
if (!res.ok) throw new Error(`${res.status}: ${(await res.json()).error}`);
return res.json();
}
let statusTimer;
function say(msg, isError = false) {
const el = document.getElementById('status');
el.textContent = msg;
el.classList.toggle('error', isError);
el.classList.add('show');
clearTimeout(statusTimer);
statusTimer = setTimeout(() => el.classList.remove('show'), 4000);
}
/* ---------------- shared helpers ---------------- */
const fmt = (cents) =>
(cents < 0 ? '-$' : '$') + (Math.abs(cents) / 100).toFixed(2);
const fmtDate = (ms) => (ms ? new Date(ms).toISOString().slice(0, 10) : '—');
const customerName = (id) => state.customers.find((c) => c.id === id)?.name ?? id;
function customerOptions(selected, includeAll = false) {
const all = includeAll ? `` : '';
return (
all +
state.customers
.map((c) => ``)
.join('')
);
}
function currentPeriod() {
return new Date().toISOString().slice(0, 7);
}
async function load() {
state.pricing = await api('/pricing');
state.customers = (await api('/customers')).customers;
state.invoices = (await api('/invoices')).invoices;
state.users = (await api('/users')).users;
renderEndpoints();
renderTiers();
renderCustomers();
renderStatement();
renderInvoices();
renderReports();
renderSystem();
renderUsers();
}
/* ---------------- rate card ---------------- */
function ruleInputs(id, rule) {
const fields =
rule.kind === 'fixed'
? { fixedCents: rule.fixedCents }
: rule.kind === 'variable'
? { baseCents: rule.baseCents, perKbCents: rule.perKbCents, perMbCents: rule.perMbCents }
: {};
return Object.entries(fields)
.map(
([k, v]) =>
``,
)
.join('');
}
function renderEndpoints() {
const rows = Object.entries(state.pricing.rateCard.endpoints)
.map(
([id, rule]) => `
| ${id} |
${rule.kind} |
|
${ruleInputs(id, rule)} |
|
`,
)
.join('');
document.getElementById('endpoints').innerHTML = `
Rate card
Per-endpoint list prices, in cents. Changes apply to the next API call — no restart.
| Endpoint (operationId) | Kind | Set kind | Prices (cents) | |
${rows}
`;
}
async function saveEndpoint(id) {
const kind = document.querySelector(`[data-endpoint-kind="${id}"]`).value;
const rule = { kind };
document.querySelectorAll(`input[data-endpoint="${id}"]`).forEach((el) => {
rule[el.dataset.field] = Number(el.value);
});
if (kind === 'fixed' && rule.fixedCents === undefined) rule.fixedCents = 0;
if (kind === 'variable') {
rule.baseCents = rule.baseCents ?? 0;
rule.perKbCents = rule.perKbCents ?? 0;
rule.perMbCents = rule.perMbCents ?? 0;
}
await api(`/endpoints/${id}`, { method: 'PUT', body: JSON.stringify(rule) });
say(`Saved ${id}.`);
await load();
}
async function deleteEndpoint(id) {
await api(`/endpoints/${id}`, { method: 'DELETE' });
say(`Deleted ${id} — calls to it now get 403 unless a tier has a default rule.`);
await load();
}
async function addEndpoint() {
const id = document.getElementById('new-endpoint-id').value.trim();
const kind = document.getElementById('new-endpoint-kind').value;
if (!id) return say('Endpoint id required.', true);
const rule =
kind === 'free'
? { kind }
: kind === 'fixed'
? { kind, fixedCents: 0 }
: { kind, baseCents: 0, perKbCents: 0, perMbCents: 0 };
await api(`/endpoints/${id}`, { method: 'PUT', body: JSON.stringify(rule) });
say(`Added ${id}.`);
await load();
}
/* ---------------- customer types ---------------- */
function renderTiers() {
const rows = state.pricing.tiers
.map(
(t) => `
| ${t.id} |
|
|
|
|
`,
)
.join('');
document.getElementById('tiers').innerHTML = `
Customer types
Multiplier scales every list price (0.5 = 50%). Monthly credit is free included usage, in cents.
| Id | Name | Multiplier | Monthly credit (cents) | |
${rows}
`;
}
async function saveTier(id) {
const body = { id };
document.querySelectorAll(`[data-tier="${id}"]`).forEach((el) => {
body[el.dataset.field] = el.type === 'number' ? Number(el.value) : el.value;
});
const existing = state.pricing.tiers.find((t) => t.id === id);
if (existing?.defaultRule) body.defaultRule = existing.defaultRule;
await api(`/tiers/${id}`, { method: 'PUT', body: JSON.stringify(body) });
say(`Saved tier ${id}.`);
await load();
}
async function deleteTier(id) {
await api(`/tiers/${id}`, { method: 'DELETE' });
say(`Deleted tier ${id}.`);
await load();
}
async function addTier() {
const id = document.getElementById('new-tier-id').value.trim();
const name = document.getElementById('new-tier-name').value.trim();
const multiplier = Number(document.getElementById('new-tier-multiplier').value);
if (!id || !name) return say('Tier id and name required.', true);
await api(`/tiers/${id}`, {
method: 'PUT',
body: JSON.stringify({ id, name, multiplier, monthlyCreditCents: 0 }),
});
say(`Added tier ${id}.`);
await load();
}
/* ---------------- customers ---------------- */
function renderCustomers() {
const tierOptions = (selected) =>
state.pricing.tiers
.map((t) => ``)
.join('');
const btOptions = (selected) =>
['stripe', 'purchase_order']
.map((b) => ``)
.join('');
const rows = state.customers
.map(
(c) => `
| ${c.id} |
${c.name} |
|
|
|
|
|
`,
)
.join('');
document.getElementById('customers').innerHTML = `
Customers
Assign types, billing method, and per-customer deals. A multiplier override replaces the type multiplier for that customer.
| Id | Name | Email | Type | Multiplier override | Billing | |
${rows}
`;
}
async function saveCustomer(id) {
const body = {};
document.querySelectorAll(`[data-customer="${id}"]`).forEach((el) => {
if (el.value === '') return;
body[el.dataset.field] = el.type === 'number' ? Number(el.value) : el.value;
});
await api(`/customers/${id}`, { method: 'PUT', body: JSON.stringify(body) });
say(`Saved customer ${id}.`);
await load();
}
function renderStatement() {
const options = customerOptions(state.customers[0]?.id || '');
document.getElementById('statement').innerHTML = `
Customer statement
Credits, prepaid balance, usage, and payments. Prefers NATS account-balance.
`;
}
async function reviewCustomer(id) {
if (!id) return;
const st = await api(`/statement/${id}`);
const row = (list, cols) =>
(list || [])
.map((r) => `${cols.map((c) => `| ${r[c] ?? ''} | `).join('')}
`)
.join('') || `| None. |
`;
const html = `
${st.name || id} · prepaid ${fmt(st.prepaidCents || 0)} · source ${st.source || 'local'}
Credits
| Cents | Reason | Agent | When |
${row(st.credits, ['cents', 'reason', 'agent', 'at'])}
Usage
| Endpoint | Cents | When |
${row(st.usage, ['endpointId', 'cents', 'at'])}
Payments
| Cents | Kind | Reason | When |
${row(st.payments, ['cents', 'kind', 'reason', 'at'])}
`;
const out = document.getElementById('stmt-out');
if (out) out.innerHTML = html;
else {
document.getElementById('statement').innerHTML = `Customer statement
${html}`;
document.querySelectorAll('nav button').forEach((b) => b.classList.toggle('active', b.dataset.tab === 'statement'));
document.querySelectorAll('main section').forEach((s) => (s.hidden = s.id !== 'statement'));
}
const sel = document.getElementById('stmt-customer');
if (sel) sel.value = id;
say(`Loaded statement for ${id}.`);
}
async function addCustomer() {
const name = document.getElementById('new-customer-name').value.trim();
const tierId = document.getElementById('new-customer-tier').value;
if (!name) return say('Customer name required.', true);
const created = await api('/customers', {
method: 'POST',
body: JSON.stringify({ name, tierId }),
});
say(`Created ${created.id} — API key: ${created.apiKey}`);
await load();
}
/* ---------------- invoices ---------------- */
function renderInvoices() {
const rows = state.invoices
.slice()
.sort((a, b) => b.id.localeCompare(a.id))
.map((inv) => {
const actions = [];
actions.push(``);
if (inv.status === 'draft')
actions.push(``);
if (inv.status === 'issued')
actions.push(``);
return `
| ${inv.id} |
${customerName(inv.customerId)} |
${inv.period} |
${inv.status} |
${inv.billingType === 'stripe' ? 'Stripe' : 'PO'}${inv.poNumber ? ` ${inv.poNumber}` : ''} |
${fmt(inv.totalCents)} |
${fmt(inv.creditCents)} |
${fmt(inv.billableCents)} |
${fmtDate(inv.dueAtMs)} |
${actions.join('')} |
`;
})
.join('');
document.getElementById('invoices').innerHTML = `
Invoices
Generate monthly invoices from metered usage, then issue and collect. Regenerating a period replaces drafts and skips issued/paid invoices.
`;
}
async function generateInvoices() {
const period = document.getElementById('gen-period').value;
const customerId = document.getElementById('gen-customer').value;
const poNumber = document.getElementById('gen-po').value.trim();
if (!period) return say('Pick a period first.', true);
const result = await api('/invoices/generate', {
method: 'POST',
body: JSON.stringify({
period,
...(customerId ? { customerId } : {}),
...(poNumber ? { poNumber } : {}),
}),
});
const skips = result.skipped
.map((s) => `${customerName(s.customerId)}: ${s.reason}`)
.join('');
document.getElementById('gen-result').innerHTML =
`Generated ${result.generated.length}: ${result.generated.join(', ') || '—'}
` +
(skips ? `` : '');
say(`Generated ${result.generated.length} invoice(s), skipped ${result.skipped.length}.`);
await load();
}
async function refreshInvoices() {
const params = new URLSearchParams();
const customerId = document.getElementById('inv-filter-customer').value;
const period = document.getElementById('inv-filter-period').value;
const status = document.getElementById('inv-filter-status').value;
if (customerId) params.set('customerId', customerId);
if (period) params.set('period', period);
if (status) params.set('status', status);
state.invoices = (await api(`/invoices?${params}`)).invoices;
renderInvoices();
}
async function invoiceAction(id, action) {
await api(`/invoices/${id}/${action}`, { method: 'POST', body: '{}' });
say(action === 'issue' ? `Issued ${id}.` : `Marked ${id} paid.`);
await load();
}
async function viewInvoice(id) {
const res = await fetch(`/admin/api/invoices/${id}?format=html`, {
headers: { authorization: `Bearer ${token()}` },
});
if (!res.ok) return say(`Could not load ${id}.`, true);
const blob = await res.blob();
window.open(URL.createObjectURL(blob), '_blank');
}
/* ---------------- reports ---------------- */
function renderReports() {
const periodStart = `${currentPeriod()}-01`;
document.getElementById('reports').innerHTML = `
Reports
Billing and usage analytics across customers. All amounts in USD, converted from integer cents.
Usage trend
`;
runReport().catch((err) => say(err.message, true));
runTrend().catch((err) => say(err.message, true));
}
function reportQuery() {
const params = new URLSearchParams();
const from = document.getElementById('rep-from').value;
const to = document.getElementById('rep-to').value;
const customerId = document.getElementById('rep-customer').value;
const billingType = document.getElementById('rep-billing-type').value;
if (from) params.set('from', from);
if (to) params.set('to', to);
if (customerId) params.set('customerId', customerId);
if (billingType) params.set('billingType', billingType);
return params;
}
async function runReport() {
const { rows } = await api(`/reports/billing?${reportQuery()}`);
state.report = rows;
const totals = rows.reduce(
(acc, r) => ({
calls: acc.calls + r.calls,
totalCents: acc.totalCents + r.totalCents,
creditCents: acc.creditCents + r.creditCents,
billableCents: acc.billableCents + r.billableCents,
}),
{ calls: 0, totalCents: 0, creditCents: 0, billableCents: 0 },
);
document.getElementById('rep-summary').innerHTML = `
Calls
${totals.calls.toLocaleString()}
Gross usage
${fmt(totals.totalCents)}
Credits applied
${fmt(totals.creditCents)}
Billable
${fmt(totals.billableCents)}
`;
document.getElementById('rep-table').innerHTML = `
| Customer | Billing | Calls | Gross | Credit | Billable |
${
rows
.map(
(r) => `
| ${r.name} ${r.customerId} |
${r.billingType === 'stripe' ? 'Stripe' : 'PO'} |
${r.calls.toLocaleString()} |
${fmt(r.totalCents)} |
${fmt(r.creditCents)} |
${fmt(r.billableCents)} |
`,
)
.join('') || '| No usage in range. |
'
}`;
}
async function downloadCsv() {
const params = reportQuery();
params.set('format', 'csv');
const res = await fetch(`/admin/api/reports/billing?${params}`, {
headers: { authorization: `Bearer ${token()}` },
});
if (!res.ok) return say('CSV download failed.', true);
const url = URL.createObjectURL(await res.blob());
const a = document.createElement('a');
a.href = url;
a.download = 'billing-report.csv';
a.click();
URL.revokeObjectURL(url);
say('CSV downloaded.');
}
async function runTrend() {
const bucket = document.getElementById('trend-bucket').value;
const params = reportQuery();
params.delete('billingType');
params.set('bucket', bucket);
const { points } = await api(`/reports/usage-trend?${params}`);
state.trend = points;
document.getElementById('trend-chart').innerHTML = points.length
? trendChart(points)
: 'No usage in range.
';
}
function trendChart(points) {
const W = 920;
const H = 220;
const padL = 8;
const padB = 34;
const padT = 10;
const max = Math.max(...points.map((p) => p.cents), 1);
const band = (W - padL) / points.length;
const barW = Math.max(4, Math.min(48, band * 0.62));
const bars = points
.map((p, i) => {
const h = ((H - padB - padT) * p.cents) / max;
const x = padL + i * band + (band - barW) / 2;
const y = H - padB - h;
const label =
points.length <= 31 || i % Math.ceil(points.length / 31) === 0
? `${p.bucket.slice(5)}`
: '';
return `${p.bucket}: ${p.calls} calls, ${fmt(p.cents)}${label}`;
})
.join('');
return `
Hover a bar for exact calls and amount. Peak: ${fmt(max)}.
`;
}
/* ---------------- system ---------------- */
function renderSystem() {
document.getElementById('system').innerHTML = `
System
Integration health and current-period billing snapshot.
Current period (${currentPeriod()})
`;
loadSystem().catch((err) => say(err.message, true));
}
async function loadSystem() {
const status = await api('/zapier/status');
state.system = status;
document.getElementById('sys-zapier').innerHTML = `
- App directory
- ${status.appDirPresent ? '✓ zapier-app/ found' : '✗ not found'}
- Version
- ${status.version ?? '—'}
- Triggers
- ${status.triggers.length ? status.triggers.join(', ') : '—'}
- Creates
- ${status.creates.length ? status.creates.join(', ') : '—'}
`;
const params = new URLSearchParams({ from: `${currentPeriod()}-01` });
const { rows } = await api(`/reports/billing?${params}`);
const totals = rows.reduce(
(acc, r) => ({ calls: acc.calls + r.calls, billableCents: acc.billableCents + r.billableCents }),
{ calls: 0, billableCents: 0 },
);
const unpaid = state.invoices.filter((i) => i.status === 'issued');
document.getElementById('sys-period').innerHTML = `
Calls this period
${totals.calls.toLocaleString()}
Billable this period
${fmt(totals.billableCents)}
Open invoices
${unpaid.length}
Open amount
${fmt(unpaid.reduce((s, i) => s + i.billableCents, 0))}
`;
}
/* ---------------- admin users ---------------- */
function renderUsers() {
const rows = state.users
.slice()
.sort((a, b) => a.username.localeCompare(b.username))
.map(
(u) => `
| ${u.username} |
${u.active ? 'active' : 'inactive'} |
${fmtDate(u.createdMs)} |
|
`,
)
.join('');
document.getElementById('users').innerHTML = `
Admin users
Accounts that can sign in to this console. Passwords are stored as scrypt hashes — never in plain text. The last active admin cannot be deactivated.
| Username | Status | Created | |
${rows}
`;
}
async function addUser() {
const username = document.getElementById('new-user-name').value.trim();
const password = document.getElementById('new-user-password').value;
if (!username || !password) return say('Username and password required.', true);
await api('/users', { method: 'POST', body: JSON.stringify({ username, password }) });
say(`Created admin user ${username}.`);
await load();
}
async function toggleUser(username, activate) {
await api(`/users/${username}/${activate ? 'activate' : 'deactivate'}`, {
method: 'POST',
body: '{}',
});
say(`${activate ? 'Activated' : 'Deactivated'} ${username}.`);
await load();
}
/* ---------------- tabs + boot ---------------- */
document.querySelectorAll('nav button').forEach((btn) =>
btn.addEventListener('click', () => {
document.querySelectorAll('nav button').forEach((b) => b.classList.remove('active'));
btn.classList.add('active');
document.querySelectorAll('main section').forEach((s) => (s.hidden = true));
document.getElementById(btn.dataset.tab).hidden = false;
}),
);
if (token()) {
showShell();
load().catch((err) => say(err.message, true));
} else {
showLogin();
}