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.

${rows}
Endpoint (operationId)KindSet kindPrices (cents)

Add endpoint

The operationId must match an operation in openapi.yaml.

`; } 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.

${rows}
IdNameMultiplierMonthly credit (cents)

Add customer type

multiplier

New types start with 0 monthly credit — edit after adding.

`; } 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.

${rows}
IdNameEmailTypeMultiplier overrideBilling

Add customer

The new customer's API key is shown once in the notification — copy it immediately. Set email and billing method after creating.

`; } 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

${row(st.credits, ['cents', 'reason', 'agent', 'at'])}
CentsReasonAgentWhen

Usage

${row(st.usage, ['endpointId', 'cents', 'at'])}
EndpointCentsWhen

Payments

${row(st.payments, ['cents', 'kind', 'reason', 'at'])}
CentsKindReasonWhen
`; 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.

Generate invoices

${rows || ''}
InvoiceCustomerPeriodStatusBillingTotalCreditDue amountDue date
No invoices yet — generate a period above.
`; } 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.

    Billing report

    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 = ` CustomerBillingCallsGrossCreditBillable ${ 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 `${bars}

    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.

    Zapier integration

    Loading…

    Current period (${currentPeriod()})

    Loading…

    `; 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.

    ${rows}
    UsernameStatusCreated

    Add admin user

    Usernames may contain letters, digits, dots, dashes, and underscores. Deactivated users are blocked from signing in immediately.

    `; } 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(); }