Initial import of zappier-edge from zapier monorepo

This commit is contained in:
George Lambert 2026-09-11 18:37:06 -04:00
commit 3acde50c91
131 changed files with 21037 additions and 0 deletions

BIN
.DS_Store vendored Normal file

Binary file not shown.

5
.env.example Normal file
View file

@ -0,0 +1,5 @@
# Copy to .env (gitignored) and fill in your real Stripe secret key.
# The billing job (src/jobs/report-usage.ts) loads this via dotenv.
STRIPE_SECRET_KEY=sk_test_replace_me
# Optional: override the SQLite database location (default: zappier.db)
# ZAPPIER_DB=/absolute/path/to/zappier.db

5
.gitignore vendored Normal file
View file

@ -0,0 +1,5 @@
node_modules/
dist/
.env
zappier.db
zappier.db-journal

12
Dockerfile Normal file
View file

@ -0,0 +1,12 @@
FROM node:20-bookworm-slim
WORKDIR /app
COPY package.json package-lock.json ./
RUN npm ci
COPY tsconfig.json ./
COPY src ./src
COPY admin ./admin
COPY portal ./portal
COPY openapi.yaml ./
ENV PORT=3000
EXPOSE 3000
CMD ["npx", "ts-node", "src/index.ts"]

10
NATS.md Normal file
View file

@ -0,0 +1,10 @@
# NATS — zappier-edge
This process **does not subscribe or publish** on NATS.
| Direction | Address | Peer | Body |
|-----------|---------|------|------|
| IN HTTPS | `/v1/*` | Zapier Platform app | JSON + `x-api-key` |
| OUT HTTPS | middleware `/zapier/v1/*` | verae-middleware | same tenant request |
NATS is private to middleware workers and archives.

142
README.md Normal file
View file

@ -0,0 +1,142 @@
# Zappier
Metered API platform: per-endpoint pricing, customer types with multipliers and
monthly credits, a usage ledger, Stripe metered billing, purchase-order
invoicing, a company admin console, a self-service customer portal, and a
Zapier integration.
This is the **billing and user platform** (originally the standalone `zappier` git tree at `~/zappier`). Forgejo name: **zappier-edge**. It owns signup, TOTP, API keys, rate card, Stripe meter, PO invoices, admin users, and the customer portal. Verae middleware does not replace this.
**Forgejo:** https://git.georgelambert.org/marchon/zappier-edge
**Catalog README:** https://zapier.georgelambert.org/packages/zappier/README.pdf
**Bring the whole system online:** https://zapier.georgelambert.org/packages/verae-ops/GETTING-STARTED.pdf
## Documentation (public PDFs)
- [USER-MANUAL](https://zapier.georgelambert.org/packages/zappier/docs/USER-MANUAL.pdf) — operations & usage
- [ACCOUNTING](https://zapier.georgelambert.org/packages/zappier/docs/ACCOUNTING.pdf) — invoices, PO billing, reports
- [USER-MANAGEMENT](https://zapier.georgelambert.org/packages/zappier/docs/USER-MANAGEMENT.pdf) — pricing, customer types
- [CUSTOMER-PORTAL](https://zapier.georgelambert.org/packages/zappier/docs/CUSTOMER-PORTAL.pdf) — signup, 2FA, reloads
- [DEVELOPER](https://zapier.georgelambert.org/packages/zappier/docs/DEVELOPER.pdf)
- [WALKTHROUGH](https://zapier.georgelambert.org/packages/zappier/docs/WALKTHROUGH.pdf)
- Markdown copies remain next to these files in `docs/`
- **Sample Zapier app:** `zapier-app/` — https://zapier.georgelambert.org/packages/zappier/zapier-app/README.pdf (if present) or the source tree in git
## Surfaces
| Surface | URL | Audience |
|---|---|---|
| Public API | `/v1/*` | API customers (`x-api-key`) |
| Interactive API docs | `/docs` | Integrating developers |
| Admin console | `/admin` | Company ops & accounting |
| Customer portal | `/portal` | End-user customers (signup, 2FA, billing) |
| Zapier app | `zapier-app/` | No-code users via Zapier |
## Pricing model
`openapi.yaml` defines the API surface; each `operationId` is a rate-card key.
Endpoints carry **list prices** (seed: `src/pricing.ts``DEFAULT_RATE_CARD`).
Customer types are **tier configs** (`DEFAULT_TIERS`) with a `multiplier`, a
`monthlyCreditCents` quota, and an optional `defaultRule` for endpoints not on the card.
Individual customers can carry a `multiplierOverride`.
Billed price = `round(list price × multiplier)`; usage up to the monthly credit is free.
Pricing is editable at runtime in the admin console.
### Seed rate card (list prices, cents per call)
| Endpoint | Model | List price |
| -------------- | -------- | -------------------------------------------- |
| `status` | free | 0 |
| `storage-list` | free | 0 |
| `transform` | fixed | 4 |
| `storage` | variable | 10 + 1 per KB metadata + 50 per MB attached |
### Seed customer types
| Tier | Multiplier | Monthly credit | Default rule (unlisted endpoints) |
| ---------- | ---------- | -------------- | --------------------------------- |
| `free` | 1.0 | 100 cents | none — call rejected with 403 |
| `pro` | 0.5 | 1000 cents | fixed 8 list → 4 billed |
| `business` | 0.25 | 10000 cents | fixed 8 list → 2 billed |
Adding a new API call = add it to `openapi.yaml`, then price it in the admin UI.
Adding a customer type = create it in the admin UI. Variable pricing = base per call +
metadata size (rounded up to KB) + attachment size (rounded up to MB), then the multiplier.
## Quickstart
```sh
npm install
npm run dev
```
The server starts on port 3000. API docs at
[http://localhost:3000/docs](http://localhost:3000/docs), admin console at
`/admin`, customer portal at `/portal`.
## Deploying
```sh
npm ci && npm run build
node dist/index.js # runs from ANY working directory
```
All runtime paths (SQLite default, `.env`, OpenAPI spec, static assets)
resolve from the installation root, so the compiled server works under
systemd, Docker, or cron regardless of cwd. `PORT` and `ZAPPIER_DB` remain
environment-overridable.
## Environment variables
| Variable | Default | Purpose |
| ------------------- | -------------- | --------------------------------------------------- |
| `PORT` | `3000` | HTTP port the server listens on |
| `ZAPPIER_DB` | `<root>/zappier.db` | SQLite database file path |
| `ADMIN_KEY` | `admin-dev-key`| Admin UI / admin API key — **set a real secret in production** |
| `ADMIN_USER` | `admin` | Admin UI primary login username |
| `DEMO_ADMIN_USER` | `demo` | Admin UI demo login username |
| `DEMO_ADMIN_PASSWORD` | `$$$Adm1n###` | Demo login password — **override in production** |
| `STRIPE_SECRET_KEY` | _(none)_ | Stripe secret key — billing job and portal reloads |
## Billing
Usage is reported to Stripe by a job (loads `STRIPE_SECRET_KEY` from `.env`):
```sh
npx ts-node src/jobs/report-usage.ts
```
The job sums each customer's usage since the first of the current month (UTC),
applies the tier's monthly credit, and reports only the **delta** above what was
already reported — re-runs are safe. Idempotency comes from three layers: a
`billing_reports` ledger (cumulative cents per customer per month), an atomic
`job_locks` run guard (1 h TTL), and a deterministic Stripe event `identifier`
(`customer:period:billable`) that dedupes crash retries. It requires a Stripe
meter named `zappier.api_cents` with Sum aggregation over the `value` field.
A Kimi cron job ("Zappier billing · report usage to Stripe") runs it daily at
06:17 America/New_York with a completion notification.
Purchase-order customers are invoiced manually from the admin console
(**Invoices** tab); prepaid balances from the customer portal are drawn down
automatically at invoice issue. See `docs/ACCOUNTING.md`.
## Zapier app
The companion Zapier integration lives in `zapier-app/`:
```sh
cd zapier-app
npm install
npm test
```
To deploy it, create a Zapier developer account, run `zapier login`, then
`zapier push` from the `zapier-app/` directory.
## Testing
```sh
npm test # root API/service suite (jest, 165 tests)
cd zapier-app && npm test # Zapier integration suite (mocha, 4 tests)
```

11
SUMMARY.md Normal file
View file

@ -0,0 +1,11 @@
# zappier-edge
**Job:** Metered commercial API, portal, admin, Stripe. Zapiers only public HTTPS hop.
**Expects:** Zapier Platform app with `x-api-key`.
**Sends:** HTTPS to middleware `/zapier/v1/*` (or local mock `/v1/timestamp`, `/v1/add`, `/v1/hashes`, `/v1/receipts`).
**Does not** speak NATS or `api.veraetime.net`.
**Test:** `npm test` (Jest, 177+).

810
admin/app.js Normal file
View file

@ -0,0 +1,810 @@
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;
const EMPTY_SVG =
'<svg width="80" height="64" viewBox="0 0 80 64" fill="none" aria-hidden="true"><rect x="16" y="8" width="48" height="48" rx="8" fill="#eef0fe"/><rect x="24" y="20" width="32" height="4" rx="2" fill="#4f46e5" opacity=".35"/><rect x="24" y="30" width="24" height="4" rx="2" fill="#4f46e5" opacity=".2"/><rect x="24" y="40" width="28" height="4" rx="2" fill="#4f46e5" opacity=".2"/></svg>';
function customerOptions(selected, includeAll = false) {
const all = includeAll ? `<option value="">All customers</option>` : '';
return (
all +
state.customers
.map((c) => `<option value="${c.id}" ${c.id === selected ? 'selected' : ''}>${c.name} (${c.id})</option>`)
.join('')
);
}
function currentPeriod() {
return new Date().toISOString().slice(0, 7);
}
const TAB_PERM = {
endpoints: 'admin.pricing',
tiers: 'admin.tiers',
customers: 'admin.customers',
statement: 'admin.statement',
invoices: 'admin.invoices',
reports: 'admin.reports',
system: 'admin.system',
users: 'iam.users.read',
};
function can(perm) {
const p = state.me?.permissions || ['*'];
return p.includes('*') || p.includes(perm);
}
function applyNav() {
document.querySelectorAll('aside nav button').forEach((btn) => {
const need = TAB_PERM[btn.dataset.tab];
btn.style.display = !need || can(need) ? '' : 'none';
});
}
async function load() {
state.me = await api('/me').catch(() => ({ permissions: ['*'] }));
applyNav();
if (!can('admin.pricing') && !can('admin.tiers') && !can('admin.customers')) {
if (state.me?.iamUrl) {
document.getElementById('endpoints').innerHTML =
`<h2>Staff IAM</h2><p class="lede">This console is for billing admins. Manage people at <a href="${state.me.iamUrl}" style="color:var(--accent)">${state.me.iamUrl}</a>.</p>`;
}
}
if (!can('admin.pricing') && !can('admin.tiers')) return;
state.pricing = await api('/pricing');
state.customers = can('admin.customers') ? (await api('/customers')).customers : [];
state.invoices = can('admin.invoices') ? (await api('/invoices')).invoices : [];
state.users = can('iam.users.read') || !state.me?.iam ? (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]) =>
`<label class="field"><span>${k}</span><input data-endpoint="${id}" data-field="${k}" type="number" step="any" value="${v}" size="6"></label>`,
)
.join('');
}
function renderEndpoints() {
const rows = Object.entries(state.pricing.rateCard.endpoints)
.map(
([id, rule]) => `<tr>
<td class="id">${id}</td>
<td><span class="pill ${rule.kind}">${rule.kind}</span></td>
<td><select data-endpoint-kind="${id}">
${['free', 'fixed', 'variable'].map((k) => `<option ${k === rule.kind ? 'selected' : ''}>${k}</option>`).join('')}
</select></td>
<td>${ruleInputs(id, rule)}</td>
<td class="row-actions">
<button class="btn" onclick="saveEndpoint('${id}')">Save</button>
<button class="btn ghost" onclick="deleteEndpoint('${id}')">Delete</button>
</td>
</tr>`,
)
.join('');
document.getElementById('endpoints').innerHTML = `
<h2>Rate card</h2>
<p class="lede">Per-endpoint list prices, in cents. Changes apply to the next API call no restart.</p>
<div class="card"><table>
<thead><tr><th>Endpoint (operationId)</th><th>Kind</th><th>Set kind</th><th>Prices (cents)</th><th></th></tr></thead>
<tbody>${rows}</tbody>
</table></div>
<div class="card">
<h3>Add endpoint</h3>
<input id="new-endpoint-id" placeholder="operationId">
<select id="new-endpoint-kind"><option>free</option><option selected>fixed</option><option>variable</option></select>
<button class="btn" onclick="addEndpoint()">Add</button>
<p class="hint">The operationId must match an operation in openapi.yaml.</p>
</div>`;
}
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) => `<tr>
<td class="id">${t.id}</td>
<td><input data-tier="${t.id}" data-field="name" value="${t.name}"></td>
<td><input data-tier="${t.id}" data-field="multiplier" type="number" step="any" value="${t.multiplier}" size="5"></td>
<td><input data-tier="${t.id}" data-field="monthlyCreditCents" type="number" value="${t.monthlyCreditCents}" size="8"></td>
<td class="row-actions">
<button class="btn" onclick="saveTier('${t.id}')">Save</button>
<button class="btn ghost" onclick="deleteTier('${t.id}')">Delete</button>
</td>
</tr>`,
)
.join('');
document.getElementById('tiers').innerHTML = `
<h2>Customer types</h2>
<p class="lede">Multiplier scales every list price (0.5 = 50%). Monthly credit is free included usage, in cents.</p>
<div class="card"><table>
<thead><tr><th>Id</th><th>Name</th><th>Multiplier</th><th>Monthly credit (cents)</th><th></th></tr></thead>
<tbody>${rows}</tbody>
</table></div>
<div class="card">
<h3>Add customer type</h3>
<input id="new-tier-id" placeholder="id">
<input id="new-tier-name" placeholder="name">
<input id="new-tier-multiplier" type="number" step="any" value="1" size="5"> multiplier
<button class="btn" onclick="addTier()">Add</button>
<p class="hint">New types start with 0 monthly credit edit after adding.</p>
</div>`;
}
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) => `<option ${t.id === selected ? 'selected' : ''}>${t.id}</option>`)
.join('');
const rows = state.customers
.map(
(c) => `<tr>
<td class="id">${c.id}</td>
<td>${c.name}</td>
<td>${c.email || '—'}</td>
<td><span class="pill ${c.tierId}">${c.tierId}</span></td>
<td><span class="pill ${c.billingType}">${c.billingType === 'stripe' ? 'Stripe' : 'PO'}</span></td>
<td class="row-actions">
<button class="btn ghost" type="button" onclick="reviewCustomer('${c.id}')">Statement</button>
<button class="btn" type="button" onclick="openCustomerDrawer('${c.id}')">Edit</button>
</td>
</tr>`,
)
.join('');
document.getElementById('customers').innerHTML = `
<h2>Customers</h2>
<p class="lede">List is read-only. Open Edit to change type, billing, email, or a per-customer multiplier override.</p>
<div class="card">${
rows
? `<table>
<thead><tr><th>Id</th><th>Name</th><th>Email</th><th>Type</th><th>Billing</th><th></th></tr></thead>
<tbody>${rows}</tbody>
</table>`
: `<div class="empty">${EMPTY_SVG}<h3>No customers yet</h3><p>Create one below. The API key is shown once.</p></div>`
}</div>
<div class="card">
<h3>Add customer</h3>
<input id="new-customer-name" placeholder="name">
<select id="new-customer-tier">${tierOptions(state.pricing.tiers[0]?.id)}</select>
<button class="btn" onclick="addCustomer()">Create</button>
<p class="hint">The new customer's API key is shown once in the notification copy it immediately. Set email and billing method in the edit drawer.</p>
</div>`;
}
function closeCustomerDrawer() {
document.getElementById('drawer-root').innerHTML = '';
}
function openCustomerDrawer(id) {
const c = state.customers.find((x) => x.id === id);
if (!c) return;
const tierOptions = state.pricing.tiers
.map((t) => `<option value="${t.id}" ${t.id === c.tierId ? 'selected' : ''}>${t.name} (${t.id})</option>`)
.join('');
document.getElementById('drawer-root').innerHTML = `
<div class="drawer-backdrop" onclick="closeCustomerDrawer()"></div>
<aside class="drawer" role="dialog" aria-labelledby="edit-cust-title">
<h2 id="edit-cust-title">Edit ${c.name}</h2>
<p class="lede">${c.id}</p>
<label for="edit-email">Email</label>
<input id="edit-email" type="email" value="${c.email ?? ''}" placeholder="billing@example.com">
<label for="edit-tier">Customer type</label>
<select id="edit-tier">${tierOptions}</select>
<label for="edit-mult">Multiplier override</label>
<input id="edit-mult" type="number" step="any" value="${c.multiplierOverride ?? ''}" placeholder="use type default">
<label for="edit-billing">Billing</label>
<select id="edit-billing">
<option value="stripe" ${c.billingType === 'stripe' ? 'selected' : ''}>Stripe</option>
<option value="purchase_order" ${c.billingType === 'purchase_order' ? 'selected' : ''}>Purchase order</option>
</select>
<div class="filterbar" style="margin-top:1.2rem">
<button class="btn" type="button" onclick="saveCustomer('${c.id}')">Save</button>
<button class="btn ghost" type="button" onclick="closeCustomerDrawer()">Cancel</button>
</div>
</aside>`;
}
async function saveCustomer(id) {
const body = {
email: document.getElementById('edit-email').value.trim(),
tierId: document.getElementById('edit-tier').value,
billingType: document.getElementById('edit-billing').value,
};
const mult = document.getElementById('edit-mult').value;
if (mult !== '') body.multiplierOverride = Number(mult);
await api(`/customers/${id}`, { method: 'PUT', body: JSON.stringify(body) });
say(`Saved ${customerName(id)}.`);
closeCustomerDrawer();
await load();
}
function renderStatement() {
const options = customerOptions(state.customers[0]?.id || '');
document.getElementById('statement').innerHTML = `
<h2>Customer statement</h2>
<p class="lede">Credits, prepaid balance, usage, and payments. Prefers NATS account-balance.</p>
<div class="card">
<div class="filterbar">
<label><span>Customer</span><select id="stmt-customer">${options}</select></label>
<button class="btn" onclick="reviewCustomer(document.getElementById('stmt-customer').value)">Load</button>
</div>
<div id="stmt-out"></div>
</div>`;
}
async function reviewCustomer(id) {
if (!id) return;
const st = await api(`/statement/${id}`);
const row = (list, cols) => {
if (!list || !list.length) {
return `<tr><td colspan="${cols.length}"><div class="empty" style="padding:1rem">${EMPTY_SVG}<h3>Nothing here yet</h3></div></td></tr>`;
}
return list
.map(
(r) =>
`<tr>${cols
.map((c) => {
const v = c === 'cents' ? fmt(r.cents) : c === 'at' ? fmtDate(r.at) : (r[c] ?? '—');
return `<td class="${c === 'cents' ? 'money' : ''}">${v}</td>`;
})
.join('')}</tr>`,
)
.join('');
};
const html = `
<p class="lede">${st.name || customerName(id)} · prepaid ${fmt(st.prepaidCents || 0)} · source ${st.source || 'local'}</p>
<div class="card"><h3>Credits</h3><table><thead><tr><th>Amount</th><th>Reason</th><th>Agent</th><th>When</th></tr></thead>
<tbody>${row(st.credits, ['cents', 'reason', 'agent', 'at'])}</tbody></table></div>
<div class="card"><h3>Usage</h3><table><thead><tr><th>Endpoint</th><th>Amount</th><th>When</th></tr></thead>
<tbody>${row(st.usage, ['endpointId', 'cents', 'at'])}</tbody></table></div>
<div class="card"><h3>Payments</h3><table><thead><tr><th>Amount</th><th>Kind</th><th>Reason</th><th>When</th></tr></thead>
<tbody>${row(st.payments, ['cents', 'kind', 'reason', 'at'])}</tbody></table></div>`;
const out = document.getElementById('stmt-out');
if (out) out.innerHTML = html;
else {
document.getElementById('statement').innerHTML = `<h2>Customer statement</h2>${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(`<button class="btn ghost" onclick="viewInvoice('${inv.id}')">View</button>`);
if (inv.status === 'draft')
actions.push(`<button class="btn" onclick="invoiceAction('${inv.id}','issue')">Issue</button>`);
if (inv.status === 'issued')
actions.push(`<button class="btn" onclick="invoiceAction('${inv.id}','paid')">Mark paid</button>`);
return `<tr>
<td class="id">${inv.id}</td>
<td>${customerName(inv.customerId)}</td>
<td>${inv.period}</td>
<td><span class="pill ${inv.status}">${inv.status}</span></td>
<td><span class="pill ${inv.billingType}">${inv.billingType === 'stripe' ? 'Stripe' : 'PO'}</span>${inv.poNumber ? ` <span class="id">${inv.poNumber}</span>` : ''}</td>
<td class="money">${fmt(inv.totalCents)}</td>
<td class="money">${fmt(inv.creditCents)}</td>
<td class="money"><b>${fmt(inv.billableCents)}</b></td>
<td>${fmtDate(inv.dueAtMs)}</td>
<td class="row-actions">${actions.join('')}</td>
</tr>`;
})
.join('');
document.getElementById('invoices').innerHTML = `
<h2>Invoices</h2>
<p class="lede">Generate monthly invoices from metered usage, then issue and collect. Regenerating a period replaces drafts and skips issued/paid invoices.</p>
<div class="card">
<h3>Generate invoices</h3>
<div class="filterbar">
<label><span>Period</span><input id="gen-period" type="month" value="${currentPeriod()}"></label>
<label><span>Customer</span><select id="gen-customer">${customerOptions('', true)}</select></label>
<label><span>PO number (optional)</span><input id="gen-po" placeholder="PO-1234" size="12"></label>
<button class="btn" onclick="generateInvoices()">Generate</button>
</div>
<div id="gen-result"></div>
</div>
<div class="card">
<div class="filterbar">
<label><span>Customer</span><select id="inv-filter-customer" onchange="refreshInvoices()">${customerOptions('', true)}</select></label>
<label><span>Period</span><input id="inv-filter-period" type="month" onchange="refreshInvoices()"></label>
<label><span>Status</span><select id="inv-filter-status" onchange="refreshInvoices()">
<option value="">Any</option><option>draft</option><option>issued</option><option>paid</option>
</select></label>
<button class="btn ghost" onclick="refreshInvoices()">Refresh</button>
</div>
${
rows
? `<table id="inv-table">
<thead><tr><th>Invoice</th><th>Customer</th><th>Period</th><th>Status</th><th>Billing</th><th>Total</th><th>Credit</th><th>Due amount</th><th>Due date</th><th></th></tr></thead>
<tbody>${rows}</tbody>
</table>`
: `<div class="empty">${EMPTY_SVG}<h3>No invoices yet</h3><p>Generate a period above. Issued invoices keep their numbers; drafts can be replaced.</p></div>`
}
</div>`;
}
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) => `<li>${customerName(s.customerId)}: ${s.reason}</li>`)
.join('');
document.getElementById('gen-result').innerHTML =
`<p class="hint">Generated ${result.generated.length}: ${result.generated.join(', ') || '—'}</p>` +
(skips ? `<ul class="skip-list">${skips}</ul>` : '');
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 = `
<h2>Reports</h2>
<p class="lede">Billing and usage analytics across customers. All amounts in USD, converted from integer cents.</p>
<div class="card">
<h3>Billing report</h3>
<div class="filterbar">
<label><span>From</span><input id="rep-from" type="date" value="${periodStart}"></label>
<label><span>To</span><input id="rep-to" type="date"></label>
<label><span>Customer</span><select id="rep-customer">${customerOptions('', true)}</select></label>
<label><span>Billing type</span><select id="rep-billing-type">
<option value="">Any</option><option value="stripe">Stripe</option><option value="purchase_order">Purchase order</option>
</select></label>
<button class="btn" onclick="runReport()">Run</button>
<button class="btn ghost" onclick="downloadCsv()">Download CSV</button>
</div>
<div id="rep-summary"></div>
<table id="rep-table"></table>
</div>
<div class="card">
<h3>Usage trend</h3>
<div class="filterbar">
<label><span>Bucket</span><select id="trend-bucket" onchange="runTrend()">
<option value="day">Daily</option><option value="week">Weekly</option>
</select></label>
</div>
<div id="trend-chart"></div>
</div>`;
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 = `
<div class="stat-grid">
<div class="stat"><div class="k">Calls</div><div class="v">${totals.calls.toLocaleString()}</div></div>
<div class="stat"><div class="k">Gross usage</div><div class="v">${fmt(totals.totalCents)}</div></div>
<div class="stat"><div class="k">Credits applied</div><div class="v">${fmt(totals.creditCents)}</div></div>
<div class="stat"><div class="k">Billable</div><div class="v">${fmt(totals.billableCents)}</div></div>
</div>`;
document.getElementById('rep-table').innerHTML = `
<thead><tr><th>Customer</th><th>Billing</th><th>Calls</th><th>Gross</th><th>Credit</th><th>Billable</th></tr></thead>
<tbody>${
rows
.map(
(r) => `<tr>
<td>${r.name} <span class="id">${r.customerId}</span></td>
<td><span class="pill ${r.billingType}">${r.billingType === 'stripe' ? 'Stripe' : 'PO'}</span></td>
<td class="money">${r.calls.toLocaleString()}</td>
<td class="money">${fmt(r.totalCents)}</td>
<td class="money">${fmt(r.creditCents)}</td>
<td class="money"><b>${fmt(r.billableCents)}</b></td>
</tr>`,
)
.join('') || '<tr><td colspan="6" style="color:var(--muted)">No usage in range.</td></tr>'
}</tbody>`;
}
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)
: '<p class="hint">No usage in range.</p>';
}
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
? `<text x="${x + barW / 2}" y="${H - padB + 13}" text-anchor="middle">${p.bucket.slice(5)}</text>`
: '';
return `<rect class="bar" x="${x.toFixed(1)}" y="${y.toFixed(1)}" width="${barW.toFixed(1)}" height="${Math.max(h, p.cents > 0 ? 2 : 0).toFixed(1)}"><title>${p.bucket}: ${p.calls} calls, ${fmt(p.cents)}</title></rect>${label}`;
})
.join('');
return `<svg class="chart" viewBox="0 0 ${W} ${H}" role="img" aria-label="Usage trend">${bars}</svg>
<p class="hint">Hover a bar for exact calls and amount. Peak: ${fmt(max)}.</p>`;
}
/* ---------------- system ---------------- */
function renderSystem() {
document.getElementById('system').innerHTML = `
<h2>System</h2>
<p class="lede">Integration health and current-period billing snapshot.</p>
<div class="card"><h3>Zapier integration</h3><div id="sys-zapier"><p class="hint">Loading</p></div></div>
<div class="card"><h3>Current period (${currentPeriod()})</h3><div id="sys-period"><p class="hint">Loading</p></div></div>`;
loadSystem().catch((err) => say(err.message, true));
}
async function loadSystem() {
const status = await api('/zapier/status');
state.system = status;
document.getElementById('sys-zapier').innerHTML = `
<dl class="kv">
<dt>App directory</dt><dd>${status.appDirPresent ? '✓ zapier-app/ found' : ' not found'}</dd>
<dt>Version</dt><dd>${status.version ?? ''}</dd>
<dt>Triggers</dt><dd>${status.triggers.length ? status.triggers.join(', ') : ''}</dd>
<dt>Creates</dt><dd>${status.creates.length ? status.creates.join(', ') : ''}</dd>
</dl>`;
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 = `
<div class="stat-grid">
<div class="stat"><div class="k">Calls this period</div><div class="v">${totals.calls.toLocaleString()}</div></div>
<div class="stat"><div class="k">Billable this period</div><div class="v">${fmt(totals.billableCents)}</div></div>
<div class="stat"><div class="k">Open invoices</div><div class="v">${unpaid.length}</div></div>
<div class="stat"><div class="k">Open amount</div><div class="v">${fmt(unpaid.reduce((s, i) => s + i.billableCents, 0))}</div></div>
</div>`;
}
/* ---------------- admin users ---------------- */
function renderUsers() {
if (state.me?.iam) {
document.getElementById('users').innerHTML = `
<h2>Staff users</h2>
<p class="lede">Named internal accounts and roles live in Staff IAM not the billing-console seed table.</p>
<div class="card">
<p>Open <a class="btn" style="display:inline-block;text-decoration:none" href="${state.me.iamUrl || 'http://127.0.0.1:3028/'}">${state.me.iamUrl || 'http://127.0.0.1:3028/'}</a></p>
<p class="hint">Roles: owner, iam-admin, billing-admin, cs, sales, accounting, operator, viewer.</p>
</div>`;
return;
}
const rows = state.users
.slice()
.sort((a, b) => a.username.localeCompare(b.username))
.map(
(u) => `<tr>
<td class="id">${u.username}</td>
<td><span class="pill ${u.active ? 'paid' : 'draft'}">${u.active ? 'active' : 'inactive'}</span></td>
<td>${fmtDate(u.createdMs)}</td>
<td class="row-actions">
<button class="btn ${u.active ? 'ghost' : ''}" onclick="toggleUser('${u.username}', ${!u.active})">${u.active ? 'Deactivate' : 'Activate'}</button>
</td>
</tr>`,
)
.join('');
document.getElementById('users').innerHTML = `
<h2>Admin users</h2>
<p class="lede">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.</p>
<div class="card"><table>
<thead><tr><th>Username</th><th>Status</th><th>Created</th><th></th></tr></thead>
<tbody>${rows}</tbody>
</table></div>
<div class="card">
<h3>Add admin user</h3>
<input id="new-user-name" placeholder="username" autocomplete="off">
<input id="new-user-password" type="password" placeholder="password (min 8 chars)" autocomplete="new-password">
<button class="btn" onclick="addUser()">Create</button>
<p class="hint">Usernames may contain letters, digits, dots, dashes, and underscores. Deactivated users are blocked from signing in immediately.</p>
</div>`;
}
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();
}

283
admin/index.html Normal file
View file

@ -0,0 +1,283 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>Zappier Admin</title>
<link rel="icon" href="data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 32 32'%3E%3Crect width='32' height='32' rx='8' fill='%234f46e5'/%3E%3C/svg%3E"/>
<style>
:root {
--bg: #f4f5fb;
--panel: #ffffff;
--ink: #171a26;
--muted: #6b7186;
--line: #e5e7f0;
--accent: #4f46e5;
--accent-ink: #ffffff;
--accent-soft: #eef0fe;
--danger: #dc2626;
--ok: #047857;
--radius: 12px;
--shadow: 0 1px 2px rgba(23, 26, 38, 0.05), 0 8px 24px rgba(23, 26, 38, 0.06);
}
* { box-sizing: border-box; }
body {
margin: 0;
font-family: -apple-system, "SF Pro Text", "Segoe UI", "PingFang SC", sans-serif;
background: var(--bg);
color: var(--ink);
font-size: 14px;
}
/* ---------- Login ---------- */
#login {
min-height: 100vh;
display: grid;
place-items: center;
background: linear-gradient(160deg, #312e81 0%, #4f46e5 55%, #7c74f0 100%);
}
#login .card {
width: 360px;
background: var(--panel);
border-radius: 16px;
box-shadow: 0 24px 64px rgba(17, 12, 60, 0.35);
padding: 2rem;
}
#login h1 { font-size: 1.35rem; margin: 0 0 0.25rem; }
#login p.sub { color: var(--muted); margin: 0 0 1.5rem; }
#login label { display: block; font-weight: 600; font-size: 0.8rem; margin: 0.9rem 0 0.3rem; }
#login input {
width: 100%;
padding: 0.6rem 0.75rem;
border: 1px solid var(--line);
border-radius: 8px;
font-size: 0.95rem;
}
#login input:focus { outline: 2px solid var(--accent); border-color: transparent; }
#login button {
width: 100%;
margin-top: 1.4rem;
padding: 0.65rem;
border: 0;
border-radius: 8px;
background: var(--accent);
color: var(--accent-ink);
font-weight: 700;
font-size: 0.95rem;
cursor: pointer;
}
#login button:hover { filter: brightness(1.08); }
#login-error { color: var(--danger); font-size: 0.85rem; min-height: 1.2em; margin: 0.6rem 0 0; }
/* ---------- Shell ---------- */
#shell { display: none; min-height: 100vh; }
#shell.on { display: grid; grid-template-columns: 232px 1fr; }
aside {
background: var(--panel);
border-right: 1px solid var(--line);
padding: 1.25rem 0.9rem;
display: flex;
flex-direction: column;
gap: 0.25rem;
position: sticky;
top: 0;
height: 100vh;
}
.brand { display: flex; align-items: center; gap: 0.6rem; padding: 0.25rem 0.6rem 1.1rem; }
.brand .dot {
width: 30px; height: 30px; border-radius: 9px;
background: linear-gradient(140deg, var(--accent), #8b85f2);
display: grid; place-items: center; color: #fff; font-weight: 800;
}
.brand b { font-size: 1.02rem; }
nav button {
display: flex; align-items: center; gap: 0.55rem;
width: 100%;
border: 0; background: none;
text-align: left;
padding: 0.55rem 0.7rem;
border-radius: 8px;
font-size: 0.92rem;
color: var(--muted);
cursor: pointer;
}
nav button { min-height: 40px; }
nav button:hover { background: var(--bg); color: var(--ink); }
nav button.active { background: var(--accent-soft); color: var(--accent); font-weight: 700; }
nav button:focus-visible, #logout:focus-visible, button.btn:focus-visible, a:focus-visible {
outline: 2px solid var(--accent); outline-offset: 2px;
}
.skip { position:absolute; left:-999px; }
.skip:focus { left:1rem; top:1rem; z-index:40; background:#fff; color:var(--accent); padding:.5rem .9rem; border-radius:8px; }
.empty { text-align:center; padding:1.6rem 1rem; color:var(--muted); }
.empty svg { display:block; margin:0 auto .6rem; }
.empty h3 { margin:0 0 .25rem; color:var(--ink); font-size:1rem; }
.drawer-backdrop { position:fixed; inset:0; background:rgba(23,26,38,.35); z-index:20; }
.drawer {
position:fixed; top:0; right:0; height:100vh; width:min(420px,100%);
background:#fff; box-shadow:-8px 0 32px rgba(23,26,38,.12); z-index:21;
padding:1.4rem 1.35rem 2rem; overflow:auto;
}
.drawer h2 { margin:0 0 .2rem; }
.drawer label { display:block; font-size:.72rem; font-weight:700; letter-spacing:.04em; text-transform:uppercase; color:var(--muted); margin:.85rem 0 .25rem; }
.drawer input, .drawer select { width:100%; }
aside .spacer { flex: 1; }
#logout {
border: 1px solid var(--line); background: none; border-radius: 8px;
padding: 0.5rem; color: var(--muted); cursor: pointer; font-size: 0.85rem;
}
#logout:hover { color: var(--danger); border-color: var(--danger); }
main { padding: 1.75rem 2rem 3rem; max-width: 1080px; }
main h2 { margin: 0 0 0.25rem; font-size: 1.3rem; }
main .lede { color: var(--muted); margin: 0 0 1.25rem; }
.card {
background: var(--panel);
border: 1px solid var(--line);
border-radius: var(--radius);
box-shadow: var(--shadow);
padding: 1.1rem 1.25rem;
margin-bottom: 1.25rem;
}
.card h3 { margin: 0 0 0.9rem; font-size: 0.95rem; }
table { border-collapse: collapse; width: 100%; }
th {
text-align: left; font-size: 0.72rem; text-transform: uppercase;
letter-spacing: 0.04em; color: var(--muted);
border-bottom: 1px solid var(--line); padding: 0.45rem 0.6rem;
}
td { border-bottom: 1px solid var(--line); padding: 0.55rem 0.6rem; }
tr:last-child td { border-bottom: 0; }
tbody tr:hover { background: #fafaff; }
td.id { font-family: "SF Mono", Menlo, monospace; font-size: 0.82rem; font-weight: 600; }
input, select {
padding: 0.4rem 0.55rem;
border: 1px solid var(--line);
border-radius: 7px;
font-size: 0.88rem;
background: #fff;
color: var(--ink);
}
input:focus, select:focus { outline: 2px solid var(--accent); border-color: transparent; }
input[type="number"] { width: 90px; }
.field { display: inline-flex; align-items: center; gap: 0.35rem; margin-right: 0.7rem; }
.field span { color: var(--muted); font-size: 0.78rem; font-family: "SF Mono", Menlo, monospace; }
button.btn {
border: 0; border-radius: 7px; padding: 0.42rem 0.85rem;
font-size: 0.85rem; font-weight: 600; cursor: pointer;
background: var(--accent); color: var(--accent-ink);
}
button.btn:hover { filter: brightness(1.08); }
button.btn.ghost { background: none; border: 1px solid var(--line); color: var(--muted); }
button.btn.ghost:hover { color: var(--danger); border-color: var(--danger); }
.row-actions { white-space: nowrap; text-align: right; }
.row-actions button { margin-left: 0.35rem; }
.pill {
display: inline-block; padding: 0.1rem 0.55rem; border-radius: 999px;
font-size: 0.72rem; font-weight: 700;
}
.pill.free { background: #ecfdf5; color: var(--ok); }
.pill.fixed { background: var(--accent-soft); color: var(--accent); }
.pill.variable { background: #fff7ed; color: #c2410c; }
.pill.draft { background: #f1f5f9; color: #475569; }
.pill.issued { background: #fff7ed; color: #c2410c; }
.pill.paid { background: #ecfdf5; color: var(--ok); }
.pill.stripe { background: var(--accent-soft); color: var(--accent); }
.pill.purchase_order { background: #fdf4ff; color: #a21caf; }
/* ---------- Accounting tabs ---------- */
.filterbar {
display: flex; flex-wrap: wrap; align-items: end; gap: 0.8rem;
margin-bottom: 1rem;
}
.filterbar label { display: flex; flex-direction: column; gap: 0.25rem; }
.filterbar label span {
font-size: 0.72rem; text-transform: uppercase; letter-spacing: 0.04em;
color: var(--muted); font-weight: 600;
}
.money { font-variant-numeric: tabular-nums; text-align: right; }
.stat-grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(150px, 1fr)); gap: 0.9rem; margin-bottom: 1.25rem; }
.stat {
background: var(--panel); border: 1px solid var(--line);
border-radius: var(--radius); box-shadow: var(--shadow);
padding: 0.9rem 1.1rem;
}
.stat .k { font-size: 0.72rem; text-transform: uppercase; letter-spacing: 0.05em; color: var(--muted); }
.stat .v { font-size: 1.35rem; font-weight: 800; margin-top: 0.2rem; font-variant-numeric: tabular-nums; }
.chart { width: 100%; height: auto; display: block; }
.chart .bar { fill: var(--accent); }
.chart .bar:hover { fill: #3730a3; }
.chart text { fill: var(--muted); font-size: 10px; font-family: inherit; }
.skip-list { margin: 0.6rem 0 0; padding-left: 1.1rem; color: var(--muted); font-size: 0.85rem; }
.kv { display: grid; grid-template-columns: 160px 1fr; row-gap: 0.45rem; }
.kv dt { color: var(--muted); font-size: 0.82rem; }
.kv dd { margin: 0; font-weight: 600; }
#status {
position: fixed; right: 1.25rem; bottom: 1.25rem;
background: var(--ink); color: #fff;
padding: 0.7rem 1.1rem; border-radius: 10px;
box-shadow: var(--shadow);
font-size: 0.88rem;
opacity: 0; transform: translateY(8px);
transition: opacity 0.2s, transform 0.2s;
max-width: 420px;
pointer-events: none;
}
#status.show { opacity: 1; transform: none; }
#status.error { background: var(--danger); }
.hint { color: var(--muted); font-size: 0.82rem; margin-top: 0.8rem; }
</style>
</head>
<body>
<a class="skip" href="#main">Skip to content</a>
<section id="login">
<form class="card" id="login-form">
<h1>Zappier Admin</h1>
<p class="sub">Sign in to manage pricing, customer types, and customers.</p>
<label for="login-username">Username</label>
<input id="login-username" autocomplete="username" required />
<label for="login-password">Password</label>
<input id="login-password" type="password" autocomplete="current-password" required />
<p id="login-error"></p>
<button type="submit">Sign in</button>
</form>
</section>
<div id="shell">
<aside>
<div class="brand"><span class="dot">Z</span><b>Zappier</b></div>
<nav>
<button data-tab="endpoints" class="active">&nbsp; Rate card</button>
<button data-tab="tiers">&nbsp; Customer types</button>
<button data-tab="customers">&nbsp; Customers</button>
<button data-tab="statement">&nbsp; Statement</button>
<button data-tab="invoices">&nbsp; Invoices</button>
<button data-tab="reports">&nbsp; Reports</button>
<button data-tab="system">&nbsp; System</button>
<button data-tab="users">&nbsp; Users</button>
</nav>
<div class="spacer"></div>
<button id="logout">Sign out</button>
</aside>
<main id="main">
<section id="endpoints"></section>
<section id="tiers" hidden></section>
<section id="customers" hidden></section>
<section id="statement" hidden></section>
<section id="invoices" hidden></section>
<section id="reports" hidden></section>
<section id="system" hidden></section>
<section id="users" hidden></section>
</main>
</div>
<div id="drawer-root"></div>
<p id="status"></p>
<script src="app.js"></script>
</body>
</html>

BIN
docs/.DS_Store vendored Normal file

Binary file not shown.

126
docs/ACCOUNTING.md Normal file
View file

@ -0,0 +1,126 @@
# Company Accounting Walkthrough
This guide covers the company-side accounting surface: invoicing, purchase-order
billing, reports, trends, and CSV export. Everything here runs in the **admin
console** at `/admin`.
> Audience: operations and accounting staff. For pricing and customer setup see
> `USER-MANAGEMENT.md`; for the end-user view see `CUSTOMER-PORTAL.md`.
---
## 1. Sign in
Open `http://<host>:<port>/admin` and sign in. The development credentials are
`demo` / `$$$Adm1n###` (override with `DEMO_ADMIN_USER` / `DEMO_ADMIN_PASSWORD`;
the primary account is `ADMIN_USER` / `ADMIN_KEY`).
![Admin login](screenshots/admin-login.png)
---
## 2. Billing identity per customer
Before invoicing, each customer needs a **billing type** and an **email** — set
on the **Customers** tab:
- **Stripe** — metered usage is reported to Stripe by the daily billing job.
- **Purchase order** — invoiced manually with a PO number; issued PO invoices
get a 30-day due date automatically.
![Customers with billing types](screenshots/admin-customers.png)
Customers can also carry a **prepaid balance** (funded from the customer
portal). When an invoice is issued and the balance fully covers the billable
amount, the balance is drawn down and the invoice goes straight to **paid**.
Partial coverage is left untouched — there are no partial payments.
---
## 3. Generate invoices
On the **Invoices** tab, pick a **period** (month), optionally narrow to one
customer, optionally set a **PO number**, and click **Generate**.
![Invoices tab](screenshots/admin-invoices.png)
Generation rules:
- One invoice per customer with usage in the period, grouped by endpoint.
- The tier **monthly credit** is applied; only the remainder is billable.
- Regenerating a period **replaces drafts** (e.g. after late-arriving usage)
and **skips issued/paid invoices** — the result panel lists who was skipped
and why.
- Invoice ids are `INV-<period>-<sequence>`, e.g. `INV-2026-07-0002`.
## 4. Issue, view, collect
Each row shows status, billing type, totals, and due amount:
- **Issue** (draft → issued) — finalizes the invoice; PO invoices get a due
date 30 days out. Balance-covered invoices skip straight to **paid**.
- **View** — opens the print-ready invoice in a new tab. Use the browser's
**Print → Save as PDF** to produce a PDF for the customer.
- **Mark paid** (issued → paid) — record collection for PO invoices.
![Print-ready invoice](screenshots/admin-invoice-html.png)
Filter the table by customer, period, or status with the filter bar.
---
## 5. Reports and trends
The **Reports** tab answers "who owes what, and how is usage trending?"
![Reports tab](screenshots/admin-reports.png)
- **Billing report** — per-customer calls, gross usage, credits applied, and
billable amount for a date range. Filter by customer or billing type (all
Stripe customers, all PO customers, or one customer). Summary cards total
the selection.
- **Download CSV** — the same rows as `billing-report.csv` with the active
filters, ready for the accounting package.
- **Usage trend** — daily or weekly buckets as a bar chart; hover a bar for
exact calls and amount.
The same data is available as JSON/CSV from the API:
`GET /admin/api/reports/billing?from=…&to=…&customerId=…&billingType=…&format=csv`
and `GET /admin/api/reports/usage-trend?bucket=day|week`.
---
## 6. System snapshot
The **System** tab shows integration health (Zapier app directory, version,
triggers, creates) and the current period at a glance: calls, billable amount,
open invoice count, and open amount.
![System tab](screenshots/admin-system.png)
---
## 7. Automated Stripe reporting
A daily job (`src/jobs/report-usage.ts`, scheduled separately) reports the
billable delta of every Stripe-billed customer to Stripe Billing meter events.
It is idempotent: a ledger records the cumulative reported cents per customer
per period, and only the delta since the last successful run is sent. PO
customers are excluded by having no `stripeCustomerId`.
Environment (`.env` at the project root):
```
STRIPE_SECRET_KEY=sk_live_or_test_...
ZAPPIER_DB=/absolute/path/to/zappier.db # optional
```
---
## Data notes
- Money is integer **cents** everywhere internally; the UI formats dollars.
- All accounting data lives in the SQLite database (`zappier.db` by default):
`invoices`, `invoice_lines`, `billing_ledger`, `customers`.
- The Stripe billing job and the admin console can run from any working
directory — all paths resolve from the installation root.

BIN
docs/ACCOUNTING.pdf Normal file

Binary file not shown.

98
docs/CUSTOMER-PORTAL.md Normal file
View file

@ -0,0 +1,98 @@
# Customer Portal Walkthrough
The self-service portal for end-user customers at `/portal`: account setup,
two-factor authentication, usage and balance, invoices, reloads, and API
documentation.
> Audience: your API customers. For the company side see `ACCOUNTING.md` and
> `USER-MANAGEMENT.md`.
---
## 1. Create your account
Open `http://<host>:<port>/portal` and choose **Create an account**.
![Sign up](screenshots/portal-signup.png)
- Name, email, and a password of at least 8 characters.
- You start on the **Free** plan and get an **API key immediately**.
- If the company already created an account for you (you received an API key
by email), sign up with the **same email address** — your existing account,
plan, and API key are kept and the password you choose is attached to it.
Sign-in afterwards is email + password on the same screen.
![Sign in](screenshots/portal-login.png)
---
## 2. Dashboard
![Dashboard](screenshots/portal-dashboard.png)
- **Usage this month** — gross usage since the 1st (UTC).
- **Included credit** — how much of your plan's monthly credit was consumed.
- **Billable** — what exceeds the credit (what you'd be invoiced today).
- **Prepaid balance** — funds available for automatic invoice payment.
- **Your API key** — copy it, or **regenerate** it. Regenerating invalidates
the old key immediately; use it as the `x-api-key` header.
## 3. Usage & pricing
The **API & pricing** tab shows the live rate card (free / fixed / variable
per endpoint, with size-based pricing for storage) and every plan's multiplier
and monthly credit — the same numbers the server bills from.
![API & pricing](screenshots/portal-docs.png)
The interactive API reference (Swagger UI) is linked at the top (`/docs`).
## 4. Invoices
![Invoices](screenshots/portal-invoices.png)
Your invoice history with status (`draft`, `issued`, `paid`), totals, credit,
amount due, and due date. **View / print** opens a print-ready invoice — use
the browser's **Print → Save as PDF** for a copy.
![Printable invoice](screenshots/portal-invoice-html.png)
Only your own invoices are visible; other customers' ids return "not found".
## 5. Billing: reloads & email invoicing
![Billing](screenshots/portal-billing.png)
- **Reload balance** — add $1$10,000. Your prepaid balance is **drawn down
automatically** when an invoice is issued: if it fully covers the amount
due, the invoice is paid instantly.
- **Email invoicing** — receive a copy of each new invoice by email.
## 6. Security: two-factor authentication
On the **Security** tab, click **Set up 2FA**:
![2FA setup](screenshots/portal-2fa-setup.png)
1. Scan the QR code with any authenticator app (or type the secret manually).
2. Enter the 6-digit code it shows to enable 2FA.
From then on, sign-in requires the password **and** the current code. You can
disable 2FA with a valid code from the same tab. Sessions expire after 7 days.
---
## Portal API reference
Session-based (`Authorization: Bearer <token>` from signup/login):
- `POST /portal/api/signup` · `POST /portal/api/login` · `POST /portal/api/logout`
- `GET /portal/api/me` · `POST /portal/api/api-key`
- `GET /portal/api/usage` · `GET /portal/api/pricing`
- `GET /portal/api/invoices` · `GET /portal/api/invoices/:id[?format=html]`
- `POST /portal/api/2fa/setup|enable|disable`
- `POST /portal/api/reload` · `PUT /portal/api/email-invoicing`
Security notes: passwords are stored as scrypt hashes; TOTP secrets are only
shown during setup; no hash or secret is ever returned by the API.

BIN
docs/CUSTOMER-PORTAL.pdf Normal file

Binary file not shown.

406
docs/DEVELOPER.md Normal file
View file

@ -0,0 +1,406 @@
# Zappier — Developer Documentation
**Version:** 0.2.0 · **Last updated:** 2026-07-28
Metered API platform: per-endpoint pricing, per-tier multipliers, usage
ledger, Stripe metered billing, purchase-order invoicing, a company admin
console, a customer portal with TOTP 2FA, and a Zapier integration. This
document is the full technical reference. For operations, see
[USER-MANUAL.md](USER-MANUAL.md).
---
## Table of contents
1. [Architecture](#1-architecture)
2. [Module reference](#2-module-reference)
3. [Pricing engine](#3-pricing-engine)
4. [Request lifecycle](#4-request-lifecycle)
5. [Persistence (SQLite)](#5-persistence-sqlite)
6. [Billing pipeline](#6-billing-pipeline)
7. [API reference](#7-api-reference)
8. [Zapier app](#8-zapier-app)
9. [Testing](#9-testing)
10. [Configuration](#10-configuration)
11. [Extending the system](#11-extending-the-system)
---
## 1. Architecture
**Stack:** Node 20 · TypeScript (strict) · Express 4 · better-sqlite3 ·
express-openapi-validator · swagger-ui-express · stripe SDK · Zapier Platform
(core ^19) · jest + supertest (root) and mocha (zapier-app).
```
┌────────────────────────────────────────────┐
│ Express app │
│ (src/app.ts) │
│ │
Browser ── /admin ──────┼── static admin UI (admin/) │
Browser ── /admin/api ──┼── adminAuth → adminRouter (src/admin.ts) │
│ pricing · customers · invoices · │
│ reports · zapier status │
Browser ── /portal ─────┼── static portal SPA (portal/) │
Browser ── /portal/api ─┼── portalRouter (src/portal.ts) │
│ signup/login+2FA · me · usage · │
│ invoices · reload · prefs │
Browser ── /docs ───────┼── swagger-ui (openapi.yaml) │
│ │
Client ──── /v1/* ──────┼── apiKeyAuth (src/auth.ts) │
│ └─ OpenAPI request validation │
│ └─ meter() (src/meter.ts) │
│ ├─ quoteCall() (src/pricing.ts) │
│ └─ UsageRepo.record() │
└──────────────┬─────────────────────────────┘
┌───────────────┬───────────────┼───────────────┬────────────┐
│ │ │ │ │
SqliteUsageRepo SqliteCustomerRepo SqlitePricingStore SqliteInvoiceRepo
(usage_entries) (customers) (price_endpoints, (invoices,
│ │ tiers) invoice_lines)
│ SqliteSessionRepo (portal_sessions) │
└───────────────┴───────────────┴───────────────┴────────────┘
│ zappier.db (SQLite)
Cron (daily 06:17 ET) │
"Zappier billing · report usage" │
│ │
└─► src/jobs/report-usage.ts ── reportMonthlyUsage()
├─ computeBillableCents / computeDelta (src/billing/stripe.ts)
├─ SqliteBillingReportRepo (billing_reports, job_locks)
└─ stripe.billing.meterEvents.create ──► Stripe
```
Design rules the codebase follows:
- **Ports & adapters:** `UsageRepo`, `CustomerRepo`, `PricingStore`,
`MeterEventClient` are interfaces with in-memory adapters (tests) and SQLite
/ Stripe adapters (production). Nothing outside `src/db/` and the job's
`main()` touches SQL or the Stripe SDK.
- **Live pricing reads:** the `PricingContext` getters in `buildApp` read the
store on every quote, so admin edits apply without a restart.
- **Money in integer cents everywhere.** No floats cross a boundary except
multipliers, which are applied once and rounded (`Math.round`).
## 2. Module reference
| File | Responsibility | Key exports |
|---|---|---|
| `src/index.ts` | Production entry: opens SQLite, wires repos, listens | — |
| `src/app.ts` | `buildApp(deps)` — full Express wiring; `StoredItem`; `DEFAULT_CUSTOMERS` | `buildApp`, `AppDeps` |
| `src/auth.ts` | Customer model + `x-api-key` middleware | `Customer`, `CustomerRepo`, `apiKeyAuth`, `InMemoryCustomerRepo` |
| `src/pricing.ts` | Pricing domain: rules, tiers, quote algorithm, store port | `PriceRule`, `TierConfig`, `Quote`, `quoteCall`, `PricingStore`, `DEFAULT_RATE_CARD`, `DEFAULT_TIERS` |
| `src/meter.ts` | Per-request metering middleware | `meter(endpointId, repo, pricing)` |
| `src/usage.ts` | Usage ledger domain + summaries | `UsageEntry`, `UsageSummary`, `summarize`, `UsageRepo`, `InMemoryUsageRepo` |
| `src/admin.ts` | Admin API (`/admin/api`) + admin-key guard | `adminAuth`, `adminRouter` |
| `src/billing/credit.ts` | Monthly credit application | `applyMonthlyCredit`, `BilledSummary` |
| `src/billing/stripe.ts` | Stripe-facing math + client port | `METER_EVENT_NAME`, `MeterEventClient`, `computeBillableCents`, `computeDelta`, `reportUsage` |
| `src/billing/reload.ts` | Portal reloads via Stripe PaymentIntents | `stripePaymentClient`, `hasRealStripeKey` |
| `src/accounts.ts` | Portal identity: scrypt passwords, RFC 6238 TOTP, sessions | `hashPassword`, `verifyPassword`, `totp`, `verifyTotp`, `generateTotpSecret`, `totpUri`, `SessionRepo`, `InMemorySessionRepo` |
| `src/invoicing.ts` | Invoice domain + generation | `Invoice`, `InvoiceLine`, `buildInvoice`, `InvoiceRepo`, `InMemoryInvoiceRepo` |
| `src/reports.ts` | Billing/usage aggregation + CSV | `billingRows`, `usageTrend`, `toCsv`, `BillingRow`, `TrendPoint` |
| `src/portal.ts` | Customer portal API (`/portal/api`) | `portalRouter`, `PortalDeps`, `PaymentClient`, `PaymentResult` |
| `src/paths.ts` | Installation-root resolution (cwd-independent) | `PROJECT_ROOT` |
| `src/jobs/report-usage.ts` | Billing job: delta reporting to Stripe | `reportMonthlyUsage`, `firstOfMonthUtc`, `ReportUsageDeps` |
| `src/db/usage-repo.ts` | SQLite adapter: `usage_entries` | `SqliteUsageRepo` |
| `src/db/customer-repo.ts` | SQLite adapter: `customers` (seeds when empty; idempotent column migrations) | `SqliteCustomerRepo` |
| `src/db/pricing-store.ts` | SQLite adapter: `price_endpoints`, `tiers` (seeds when empty) | `SqlitePricingStore` |
| `src/db/billing-repo.ts` | SQLite adapter: `billing_reports`, `job_locks` | `SqliteBillingReportRepo`, `BillingReportRepo`, `JobLockRepo` |
| `src/db/invoice-repo.ts` | SQLite adapter: `invoices`, `invoice_lines` | `SqliteInvoiceRepo` |
| `src/db/session-repo.ts` | SQLite adapter: `portal_sessions` | `SqliteSessionRepo` |
| `openapi.yaml` | Public API contract; drives validation and `/docs` | — |
| `admin/` | Dependency-free admin SPA (`index.html`, `app.js`) | — |
| `portal/` | Dependency-free customer portal SPA (`index.html`, `app.js`) | — |
## 3. Pricing engine
### Types (`src/pricing.ts`)
```ts
type PriceRule =
| { kind: 'free' }
| { kind: 'fixed'; fixedCents: number }
| { kind: 'variable'; baseCents: number; perKbCents: number; perMbCents: number };
interface TierConfig {
id: string; name: string;
multiplier: number; // e.g. 0.5 = 50% of list
monthlyCreditCents: number; // free included usage per month
defaultRule?: PriceRule; // fallback for endpoints with no rule
}
interface Quote {
endpointId: string;
listCents: number; // before multiplier
totalCents: number; // after multiplier — this is what is recorded
breakdown: { baseCents: number; metadataCents: number; attachmentCents: number };
}
```
### `quoteCall(pricing, tierId, endpointId, usage, multiplierOverride?)`
1. Resolve tier — throws `Unknown tier: <id>` (mapped to 403 by `meter`).
2. Resolve rule: rate-card rule for `endpointId`, else the tier's
`defaultRule`, else throw `No price rule for <tier>/<endpoint>` (403).
3. Compute breakdown:
- `free` → all zeros.
- `fixed``baseCents = fixedCents`.
- `variable``baseCents` + `perKbCents × ceil(metadataBytes/1024)` +
`perMbCents × ceil(attachmentBytes/1048576)`. Note the ceiling: 1 byte of
metadata bills a full KB unit; attachments bill per started MB.
4. `listCents` = sum of breakdown; `multiplier = multiplierOverride ?? tier.multiplier`;
`totalCents = Math.round(listCents × multiplier)`.
`totalCents` (never `listCents`) is what the usage ledger records and what the
billing pipeline sums.
## 4. Request lifecycle
For `POST /v1/storage`:
1. `express.json()` parses JSON bodies (multipart handled by the validator's
multer — 25 MB per-file cap).
2. `apiKeyAuth` (`src/auth.ts`) — `x-api-key``Customer` on `req.customer`,
else 401.
3. `express-openapi-validator` checks the request against `openapi.yaml`
(400 on violation). For `/v1/storage`, `parseMetadata` then JSON-parses the
`metadata` form field into `res.locals.parsedMetadata` (400 on bad JSON).
4. `meter('storage', usage, pricing)` (`src/meter.ts`):
- measures `metadataBytes` (UTF-8 length of the JSON-stringified metadata)
and `attachmentBytes` (sum of multer file sizes),
- calls `quoteCall` — pricing errors become 403,
- records a `UsageEntry` with `cents = quote.totalCents`,
- stashes the quote in `res.locals.quote`.
5. The route handler builds the `StoredItem` (in-memory list) and responds
`{ id, quote }`.
`GET /v1/usage` is **not** metered; it summarizes the caller's month-to-date
usage and applies the tier credit via `applyMonthlyCredit`.
## 5. Persistence (SQLite)
Single database file (`ZAPPIER_DB`, default `zappier.db`), WAL-agnostic
better-sqlite3, all tables created with `CREATE TABLE IF NOT EXISTS` in the
repo constructors. **Seeding rule:** `customers` and pricing tables seed from
`DEFAULT_CUSTOMERS` / `DEFAULT_RATE_CARD` / `DEFAULT_TIERS` only when empty.
| Table | Columns | Written by |
|---|---|---|
| `usage_entries` | `id`, `customer_id`, `endpoint_id`, `cents`, `metadata_bytes`, `attachment_bytes`, `timestamp_ms` | `meter()` on every priced call |
| `customers` | `id` PK, `name`, `tier_id`, `api_key` UNIQUE, `stripe_customer_id`, `multiplier_override`, `billing_type`, `email`, `password_hash`, `totp_secret`, `totp_enabled`, `balance_cents`, `email_invoicing` | Admin API, portal API |
| `price_endpoints` | `endpoint_id` PK, `rule_json` | Admin API |
| `tiers` | `id` PK, `name`, `multiplier`, `monthly_credit_cents`, `default_rule_json` | Admin API |
| `invoices` | `id` PK, `customer_id`, `period`, `status`, cents totals, `billing_type`, `po_number`, lifecycle timestamps | Admin API (generate/issue/paid + balance drawdown) |
| `invoice_lines` | `invoice_id`, `endpoint_id`, `calls`, `cents` | Invoice generation |
| `portal_sessions` | `token` PK, `customer_id`, `created_ms`, `expires_ms` | Portal auth |
| `billing_reports` | `customer_id` + `period` PK, `reported_cents` (cumulative), `reported_at_ms` | Billing job, after each successful meter event |
| `job_locks` | `name` PK, `acquired_at_ms` | Billing job run guard |
New customer columns are added by **idempotent migrations** (`PRAGMA
table_info` guard + `ALTER TABLE ADD COLUMN`) when the repo opens an older
database — no manual migration step.
In tests, every repo is constructed over `:memory:` databases.
## 6. Billing pipeline
### 6.1 Math (`src/billing/stripe.ts`)
```ts
computeBillableCents(entries, monthlyCreditCents)
= max(0, Σ entry.cents monthlyCreditCents)
computeDelta(billable, previouslyReported)
= max(0, billable previouslyReported)
```
`reportUsage(client, stripeCustomerId, entries, monthlyCreditCents)` is the
original whole-month reporter — **retained as public API**; the job uses the
delta path instead.
### 6.2 The job (`src/jobs/report-usage.ts`)
`reportMonthlyUsage(deps)` per run:
1. **Lock:** `locks.tryAcquireLock('report-usage', 1h TTL)` — a single atomic
`INSERT … ON CONFLICT … DO UPDATE … WHERE acquired_at_ms <= now ttl`.
Failure aborts the run; release happens in `finally`. A crashed run's lock
is taken over after the TTL.
2. **Window:** `since = firstOfMonthUtc(now)` (injectable via `deps.since` for
tests); `period = since.toISOString().slice(0, 7)` (`YYYY-MM`).
3. Per customer with a `stripeCustomerId` (others skipped silently; unknown
`tierId` warns and skips):
- `entries = usage.listFor(customer.id, since)`
- `billable = computeBillableCents(entries, tier.monthlyCreditCents)`
- `prior = billingRepo.getReportedCents(customer.id, period)` (0 for a new
month — periods are isolated by the composite PK)
- `delta = computeDelta(billable, prior)`; `delta <= 0` → log skip, continue
- `createMeterEvent({ eventName: 'zappier.api_cents',
customerId: stripeCustomerId, value: String(delta),
identifier: `${stripeCustomerId}:${period}:${billable}` })`
- **only on success:** `upsertReportedCents(customer.id, period, billable)`
— cumulative, not the delta.
**Idempotency guarantees (reviewed design):**
- *Re-run safety:* second run with same usage → delta 0 → no Stripe call.
- *Mid-month growth:* only the increase is sent; the identifier embeds the new
cumulative billable, so legitimate growth is never deduped away.
- *Crash between Stripe success and ledger write:* retry sends a byte-identical
event; Stripe drops it via the `identifier` (uniqueness enforced within a
rolling 24 h window).
- *Partial failure:* customer A's ledger write commits before customer B is
attempted; B's failure leaves A correctly recorded.
CLI: `npx ts-node src/jobs/report-usage.ts` (guarded by
`require.main === module`; loads `.env` via dotenv inside `main()`).
Scheduled by the Kimi cron job "Zappier billing · report usage to Stripe"
(`17 6 * * *`, America/New_York), which runs this command daily and reports
the outcome.
## 7. API reference
### Public API (`/v1`, auth: `x-api-key`)
Defined in `openapi.yaml`; interactive docs at `/docs`.
| Operation | Method & path | Price | Notes |
|---|---|---|---|
| `status` | GET `/v1/status` | free | Health + quote echo |
| `transform` | POST `/v1/transform` | fixed | `{text}``{output: TEXT, quote}` |
| `storage` | POST `/v1/storage` | variable | multipart: `metadata` (JSON string), `attachments[]` (≤25 MB/file) → `{id, quote}` |
| `storage-list` | GET `/v1/storage` | free | Caller's stored items |
| `usage` | GET `/v1/usage` | unmetered | Month-to-date summary with credit applied |
Error envelope: `{ "error": string }` with 400 (validation/metadata),
401 (bad key), 403 (unknown tier / no price rule), 413 (file over 25 MB).
### Admin API (`/admin/api`, auth: `x-admin-key` or login session)
| Route | Purpose |
|---|---|
| POST `/login` | `{username, password}` → session token (accounts: `ADMIN_USER`/`ADMIN_KEY`, `DEMO_ADMIN_USER`/`DEMO_ADMIN_PASSWORD`) |
| GET `/pricing` | `{ rateCard, tiers }` |
| PUT `/endpoints/:id` | Upsert a `PriceRule` (validated: 400 on bad shape) |
| DELETE `/endpoints/:id` | Remove a rule (endpoint becomes 403 for tiers without a default rule) |
| PUT `/tiers/:id` | Upsert a `TierConfig` |
| DELETE `/tiers/:id` | Remove a tier |
| GET `/customers` | List customers **without** API keys |
| POST `/customers` | Create `{name, tierId}` → full customer incl. generated `apiKey` (201, shown once) |
| PUT `/customers/:id` | Patch `name` / `tierId` / `multiplierOverride` / `stripeCustomerId` / `billingType` / `email` |
| POST `/invoices/generate` | `{period, customerId?, poNumber?}` — drafts per customer with usage; regenerating replaces drafts, skips issued/paid → `{generated, skipped}` |
| GET `/invoices` | Filters: `customerId`, `period`, `status` |
| GET `/invoices/:id` | JSON, or print-ready HTML with `?format=html` |
| POST `/invoices/:id/issue` | draft → issued (PO gets 30-day due date). **Prepaid drawdown:** if the customer's `balanceCents` fully covers `billableCents`, the balance is deducted and the invoice is saved as paid instead |
| POST `/invoices/:id/paid` | issued → paid |
| GET `/reports/billing` | `from`/`to`/`customerId`/`billingType` filters; JSON rows or `format=csv` |
| GET `/reports/usage-trend` | `bucket=day\|week`, same range filters |
| GET `/zapier/status` | Zapier app dir presence, version, triggers, creates |
### Portal API (`/portal/api`, auth: Bearer session)
| Route | Purpose |
|---|---|
| POST `/signup` | `{name, email, password≥8}` → 201 `{token, customer}`. New customer on `free` with instant API key; an email match on a passwordless (admin-created) customer **claims** that account; 409 when the email already has a password |
| POST `/login` | `{email, password, totpCode?}``{token, customer}`; 401 `totp_required` when 2FA is on and the code is missing/wrong |
| POST `/logout` | Deletes the session |
| GET `/me` | Public profile — never includes `passwordHash`/`totpSecret` |
| POST `/api-key` | Regenerates the API key (old key dies immediately) |
| GET `/usage` | Month-to-date summary with tier credit applied |
| GET `/pricing` | Live rate card + tiers for the pricing page |
| GET `/invoices` · GET `/invoices/:id` | Own invoices only (others 404); `?format=html` print view |
| POST `/2fa/setup` | Generates + stores a TOTP secret (not yet enabled) → `{secret, uri, qr}` (QR as PNG data URL via `qrcode`) |
| POST `/2fa/enable` · POST `/2fa/disable` | `{code}` verified against the stored secret |
| POST `/reload` | `{amountCents}` integer $1$10,000 via the injected `PaymentClient` — dev client credits instantly; Stripe client returns a `clientSecret` and credits on confirmation |
| PUT `/email-invoicing` | `{enabled}` preference |
Sessions live in `portal_sessions` (7-day TTL) and survive restarts.
`src/accounts.ts` implements scrypt hashing (`scrypt:N:r:p:salt:hash`,
timing-safe compare) and RFC 6238 TOTP (HMAC-SHA1, 30 s step, 6 digits,
±1 step window) with no external crypto dependency.
## 8. Zapier app
`zapier-app/` — Zapier Platform (core ^19), CommonJS, mocha tests.
| File | Purpose |
|---|---|
| `index.js` | App definition; wires auth, trigger, action |
| `authentication.js` | API-key auth; test call against `/v1/status` |
| `triggers/new_item.js` | Polling trigger: `GET /v1/storage`, newest first, dedupe by `id` |
| `creates/store_data.js` | Action: multipart `POST /v1/storage` (form-data), fields: metadata JSON + optional files |
| `test/` | mocha suite (4 tests): auth, trigger, action |
Publish flow: `zapier login``zapier push` → invite users / submit for
review. The app's base URL must point at a publicly reachable deployment of
the API server.
## 9. Testing
```bash
npm test # jest, repo root — 165 tests / 22 suites
npx tsc --noEmit # type gate
cd zapier-app && npm test # mocha — 4 tests
```
Conventions:
- **TDD** throughout; every module has in-memory adapters so tests never touch
disk or network.
- HTTP tests use **supertest** against `buildApp()` with in-memory repos.
- SQLite tests use `:memory:` databases.
- Stripe is faked by implementing `MeterEventClient`; the idempotency suite
(`tests/report-usage-idempotency.test.ts`) simulates growth, re-runs, month
rollover, Stripe throws, lock contention, and partial failure.
- The job is tested via the injectable `reportMonthlyUsage(deps)` — never by
executing `main()`.
## 10. Configuration
| Env var | Default | Used by |
|---|---|---|
| `PORT` | `3000` | `src/index.ts` |
| `ZAPPIER_DB` | `<root>/zappier.db` | `src/index.ts`, billing job |
| `ADMIN_KEY` | `admin-dev-key` | `adminAuth()` |
| `ADMIN_USER` | `admin` | Admin login |
| `DEMO_ADMIN_USER` / `DEMO_ADMIN_PASSWORD` | `demo` / `$$$Adm1n###` | Demo admin login |
| `STRIPE_SECRET_KEY` | — | Billing job; portal reloads when it starts with `sk_` (otherwise a dev payment client credits instantly) |
Both `src/index.ts` and the billing job load `.env` from the installation
root (`src/paths.ts` `PROJECT_ROOT`) — never from the process cwd — so the
compiled server and the job run from any working directory.
`.env` is gitignored (`chmod 600`); `.env.example` documents the shape.
Git identity is configured repo-local; `.gitignore` covers `node_modules/`,
`dist/`, `.env`, `zappier.db*`.
## 11. Extending the system
**Add an API endpoint:**
1. Add the path + `operationId` to `openapi.yaml` (validation & docs follow
automatically).
2. Add the route in `src/app.ts`, wrapping the handler with
`meter('<operationId>', usage, pricing)`.
3. Add a rate-card rule (admin UI or `PUT /admin/api/endpoints/<operationId>`)
— otherwise tiers without a `defaultRule` get 403.
4. Write the failing test first; keep `npm test` + `tsc` green.
**Add a customer type:** admin UI or `PUT /admin/api/tiers/:id`
(`{name, multiplier, monthlyCreditCents, defaultRule?}`).
**Swap the storage backend:** implement `UsageRepo` / `CustomerRepo` /
`PricingStore` against your database and pass them to `buildApp({...})` — no
other code changes. Same for `BillingReportRepo`/`JobLockRepo` in the job.
**Change the billing cadence:** the job is safe at any frequency (delta +
ledger + lock). The Kimi cron job controls scheduling; update its cron
expression to change cadence.
**Known intentional limitations:** stored items are in-memory (restart clears
them; usage ledger is unaffected); `reportUsage` is retained but superseded by
the delta path; Stripe identifier dedup covers a rolling 24 h window;
`releaseLock` is not owner-scoped (harmless at this job's runtime); admin
session tokens are in-memory (portal sessions are persisted); Stripe reloads
credit the balance only after payment confirmation (no webhook endpoint yet —
dev client credits instantly); email invoicing stores the preference but
sending requires SMTP wiring (deferred); PO invoices with partial prepaid
coverage are not partially paid by design.

BIN
docs/DEVELOPER.pdf Normal file

Binary file not shown.

111
docs/USER-MANAGEMENT.md Normal file
View file

@ -0,0 +1,111 @@
# User Management Walkthrough
How the company manages pricing, customer types, and customer accounts in the
**admin console** at `/admin`.
> Audience: operations staff. For invoicing/reports see `ACCOUNTING.md`; for
> the end-user view see `CUSTOMER-PORTAL.md`.
---
## 1. Admin accounts
Admin sign-ins live in the `admin_users` table — passwords are stored as
**scrypt hashes**, never in plain text. On an empty database the table is
seeded from the environment:
| Account | Username | Password (dev default) | Env override |
|---|---|---|---|
| Primary admin | `admin` | `admin-dev-key` | `ADMIN_USER` / `ADMIN_KEY` |
| Demo / stakeholder | `demo` | `$$$Adm1n###` | `DEMO_ADMIN_USER` / `DEMO_ADMIN_PASSWORD` |
The seed runs **only when the table is empty** — after that, accounts are
managed in the console and survive restarts (SQLite) and env changes.
Sign-in issues a session token; API access is also possible with the
`x-admin-key: $ADMIN_KEY` header (used by automation).
## 2. Users tab (admin account management)
The **Users** tab lists every admin account and manages their lifecycle:
![Admin users](screenshots/admin-users.png)
- **Create** — username (letters, digits, `.` `_` `-`) plus a password of at
least 8 characters. The new account can sign in immediately.
- **Deactivate / Activate** — a deactivated account is blocked from signing in
right away (401), and reactivation restores access. Deactivation persists in
the database.
- **Safety guard** — the console refuses to deactivate the **last active
admin**, so you can never lock everyone out.
Existing sessions stay valid until sign-out or server restart; deactivation
blocks *new* logins.
## 3. Rate card (per-endpoint pricing)
The **Rate card** tab sets list prices per API operation. Changes apply to the
**next API call** — no restart.
![Rate card](screenshots/admin-rate-card.png)
Three price kinds:
- **free** — never charged (e.g. `status`, `storage-list`).
- **fixed** — a flat `fixedCents` per call (e.g. `transform` at 4¢).
- **variable**`baseCents` per call plus size-based charges:
`perKbCents` per KB of metadata and `perMbCents` per MB of file attachments
(e.g. `storage`: 10¢ + 1¢/KB + 50¢/MB). This is how storing data with
metadata or attachments is priced by size.
Add an endpoint with its `operationId` from `openapi.yaml`. Deleting an
endpoint makes calls to it fail with 403 unless the customer's type has a
default rule.
## 4. Customer types (tiers)
The **Customer types** tab defines plans:
![Customer types](screenshots/admin-tiers.png)
- **Multiplier** — scales every list price (0.5 = 50% of list, 0.25 = 75% off).
- **Monthly credit (cents)** — free included usage per month, consumed before
anything is billable.
Defaults: `free` (1×, $1 credit), `pro` (0.5×, $10 credit, 8¢ default rule),
`business` (0.25×, $100 credit, 8¢ default rule).
## 5. Customers
The **Customers** tab manages individual accounts:
![Customers](screenshots/admin-customers.png)
- **Type** — assign any tier.
- **Multiplier override** — a per-customer deal that replaces the tier
multiplier (e.g. a strategic account at 0.2×).
- **Email** — used for portal login/claiming and email invoicing.
- **Billing**`Stripe` (metered via the billing job) or `Purchase order`
(manual invoicing with PO numbers and 30-day terms).
- **Create** — generates a customer id and API key. **The API key is shown
once** in the notification — copy it immediately.
Customers created here can **claim** their portal account: the first signup at
`/portal` with a matching email sets their password on the existing account
instead of creating a new one.
## 6. Admin API reference
Everything the UI does is available over HTTP (`x-admin-key` or session
Bearer):
- `POST /admin/api/login`
- `GET /admin/api/users` · `POST /admin/api/users`
· `POST /admin/api/users/:username/activate|deactivate`
- `GET /admin/api/pricing` · `PUT/DELETE /admin/api/endpoints/:id`
- `PUT/DELETE /admin/api/tiers/:id`
- `GET/POST /admin/api/customers` · `PUT /admin/api/customers/:id`
- `POST /admin/api/invoices/generate` · `GET /admin/api/invoices`
· `POST /admin/api/invoices/:id/issue|paid`
- `GET /admin/api/reports/billing` · `GET /admin/api/reports/usage-trend`
- `GET /admin/api/zapier/status`

BIN
docs/USER-MANAGEMENT.pdf Normal file

Binary file not shown.

348
docs/USER-MANUAL.md Normal file
View file

@ -0,0 +1,348 @@
# Zappier — Operations & Usage Manual
**Version:** 0.2.0 · **Last updated:** 2026-07-28
Zappier is a metered API platform: every API call your customers make is priced
per endpoint, adjusted by their customer type, tracked in a usage ledger, and
billed through Stripe once a day — or invoiced manually by purchase order.
Customers self-serve through the portal at `/portal`. This manual covers
running and operating the system. For internals, see
[DEVELOPER.md](DEVELOPER.md); for accounting procedures see
[ACCOUNTING.md](ACCOUNTING.md); for the end-user view see
[CUSTOMER-PORTAL.md](CUSTOMER-PORTAL.md).
---
## Table of contents
1. [Quick start](#1-quick-start)
2. [The three surfaces](#2-the-three-surfaces)
3. [How pricing works](#3-how-pricing-works)
4. [Operating the Pricing Admin UI](#4-operating-the-pricing-admin-ui)
5. [Using the public API](#5-using-the-public-api)
6. [Billing operations (Stripe)](#6-billing-operations-stripe)
7. [The Zapier integration](#7-the-zapier-integration)
8. [Day-to-day runbook](#8-day-to-day-runbook)
9. [Troubleshooting](#9-troubleshooting)
---
## 1. Quick start
```bash
cd /Users/marchon/zappier
npm install
npm run dev # starts the API on http://localhost:3000
```
Environment variables (all optional except `STRIPE_SECRET_KEY` for billing):
| Variable | Default | Purpose |
|---|---|---|
| `PORT` | `3000` | HTTP port for the API server |
| `ZAPPIER_DB` | `<install root>/zappier.db` | SQLite database file location |
| `ADMIN_KEY` | `admin-dev-key` | Key for the admin console and admin API |
| `ADMIN_USER` | `admin` | Admin console primary username |
| `DEMO_ADMIN_USER` / `DEMO_ADMIN_PASSWORD` | `demo` / `$$$Adm1n###` | Demo sign-in — override in production |
| `STRIPE_SECRET_KEY` | — (required for billing) | Billing job + portal reloads, loaded from `.env` |
All runtime paths (database default, `.env`, OpenAPI spec, static assets)
resolve from the installation root — the compiled server (`node
dist/index.js`) runs from any working directory, under systemd, Docker, or cron.
The `.env` file at the repo root holds `STRIPE_SECRET_KEY`. It is gitignored
and owner-only (`chmod 600`). A template is in `.env.example`.
On first start the database is created and seeded with:
- **Rate card:** `status` (free), `storage-list` (free), `transform` (fixed 4¢),
`storage` (variable: 10¢ base + 1¢/KB metadata + 50¢/MB attachments)
- **Customer types:** Free (×1.0, 100¢/month credit), Pro (×0.5, 1000¢ credit),
Business (×0.25, 10000¢ credit)
- **Demo customers:** `key-ada` (Free), `key-grace` (Pro), `key-linus` (Business)
> Seeding only happens into an **empty** database. Existing data is never
> overwritten on restart.
---
## 2. The three surfaces
| Surface | URL / location | Who it's for |
|---|---|---|
| **Public API** | `http://localhost:3000/v1/*` | Your API customers |
| **Interactive API docs** | `http://localhost:3000/docs` | Developers integrating with you |
| **Admin console** | `http://localhost:3000/admin` | You (operations & accounting) |
| **Customer portal** | `http://localhost:3000/portal` | End-user customers (self-service) |
| **Zapier app** | `zapier-app/` directory | No-code users via Zapier |
![Interactive API docs](screenshots/api-docs.png)
---
## 3. How pricing works
Every priced call returns its **quote** in the response, so customers always
know what a call cost:
```json
{
"quote": {
"endpointId": "storage",
"listCents": 62,
"totalCents": 31,
"breakdown": { "baseCents": 10, "metadataCents": 2, "attachmentCents": 50 }
}
}
```
The price of a call is computed in three steps:
1. **Endpoint rule** (from the rate card):
- `free` — always 0¢
- `fixed` — a flat `fixedCents` per call
- `variable``baseCents` + `perKbCents` × ceil(metadata bytes / 1024)
+ `perMbCents` × ceil(attachment bytes / 1 MB)
2. **Customer-type multiplier**`totalCents = round(listCents × multiplier)`.
A per-customer **multiplier override** (set in the Customers tab) wins over
the type multiplier — use it for negotiated enterprise deals.
3. **Monthly credit** — at billing time, each customer's type credit
(e.g. Pro = 1000¢) is subtracted from their month-to-date total. Only the
excess is billed.
**Worked example.** A Pro customer (×0.5) uploads a 1 MB file with 2 KB of
metadata to `storage`:
- list = 10¢ base + 2¢ metadata + 50¢ attachment = **62¢**
- Pro multiplier: 62 × 0.5 = **31¢** charged to their usage ledger
- If their month-to-date is 1500¢ and the Pro credit is 1000¢, the daily
billing job reports **500¢** to Stripe.
---
## 4. Operating the Pricing Admin UI
Open `http://localhost:3000/admin` and sign in. Two accounts are available:
| Username | Password | Purpose |
|---|---|---|
| `admin` | the `ADMIN_KEY` env value (default `admin-dev-key`) | Primary operator |
| `demo` | `$$$Adm1n###` (env `DEMO_ADMIN_PASSWORD`) | Demo / stakeholder access |
Sessions are token-based and remembered in browser local storage until you
click **Sign out** or the server restarts (tokens are in-memory — just sign in
again). The legacy `x-admin-key` header still works for scripts and curl.
### 4.1 Rate card tab
![Rate card](screenshots/admin-rate-card.png)
One row per API endpoint (matched by OpenAPI `operationId`).
- **Change a price:** edit the kind (`free` / `fixed` / `variable`) and the
cent fields, then click **Save**. Takes effect on the next API call — no
restart needed.
- **Add an endpoint:** enter the `operationId` (must match `openapi.yaml`),
pick a kind, click **Add**.
- **Delete** removes the rule. If an endpoint has no rule and the customer's
type has no default rule, calls to it are rejected with 403 — deletion is
how you turn an endpoint **off**.
### 4.2 Customer types tab
![Customer types](screenshots/admin-tiers.png)
Types are your pricing tiers.
- **Multiplier** scales every price for that type (0.5 = 50% of list).
- **Monthly credit (cents)** is the free included usage per month.
- **Add customer type:** id, name, multiplier. New types start with 0 credit;
edit after adding.
### 4.3 Customers tab
![Customers](screenshots/admin-customers.png)
- **Create** a customer: name + type. **The API key is shown once** in the
status line at the bottom — copy it immediately and send it to the customer.
- **Change type** with the dropdown, then **Save**.
- **Email** — used for portal sign-in/claiming and email invoicing.
- **Billing**`Stripe` (metered by the daily job) or `Purchase order`
(manual invoicing with PO numbers; see [ACCOUNTING.md](ACCOUNTING.md)).
- **Multiplier override**: a number here replaces the type multiplier for this
customer only. Leave blank to inherit from the type.
- Stripe customer IDs are attached via the admin API
(`PUT /admin/api/customers/:id` with `{"stripeCustomerId": "cus_..."}`) —
see section 6.
### 4.4 Invoices, Reports, System tabs
The **Invoices** tab generates monthly invoices from metered usage (per period,
optionally per customer, with optional PO number), walks them
draft → issued → paid, and opens print-ready invoice pages. The **Reports** tab
produces date-ranged billing reports (all customers, one customer, or one
billing type) with CSV download, plus daily/weekly usage-trend charts. The
**System** tab shows Zapier integration health and the current-period billing
snapshot. Full procedures: [ACCOUNTING.md](ACCOUNTING.md).
![Invoices](screenshots/admin-invoices.png)
---
## 4A. Customer portal
Your customers self-serve at `http://localhost:3000/portal`: signup (or
claiming an account you created, by email), sign-in with optional TOTP
two-factor authentication, month-to-date usage, invoice history with print
view, prepaid balance reloads (drawn down automatically at invoice issue),
email-invoicing preferences, API-key regeneration, and live pricing. The full
end-user guide is [CUSTOMER-PORTAL.md](CUSTOMER-PORTAL.md).
---
## 5. Using the public API
All calls need the customer's API key in the `x-api-key` header. Interactive
docs with a "Try it out" console are at `/docs`.
```bash
# Free status check
curl -H 'x-api-key: key-ada' http://localhost:3000/v1/status
# Fixed-price call (4¢ list)
curl -X POST -H 'x-api-key: key-grace' -H 'content-type: application/json' \
-d '{"text":"hello"}' http://localhost:3000/v1/transform
# Variable-price call: metadata + attachments
curl -X POST -H 'x-api-key: key-grace' \
-F 'metadata={"title":"Q3 report"}' \
-F 'attachments=@report.pdf' \
http://localhost:3000/v1/storage
# List your stored items (free)
curl -H 'x-api-key: key-grace' http://localhost:3000/v1/storage
# Your month-to-date usage, with credit applied
curl -H 'x-api-key: key-grace' http://localhost:3000/v1/usage
```
Limits & validation: requests are validated against `openapi.yaml` (bad
requests get 400); attachments are capped at **25 MB per file**; `metadata`
must be valid JSON (400 otherwise).
---
## 6. Billing operations (Stripe)
### 6.1 How it works
A scheduled job runs the billing reporter **daily at 06:17 America/New_York**
(Kimi cron job "Zappier billing · report usage to Stripe"). For each customer
with a Stripe ID it:
1. Sums their usage since the 1st of the month, subtracts their type's monthly
credit → **billable cents**.
2. Reports only the **delta** above what was already reported this month to
Stripe as a meter event (`zappier.api_cents`, value = cents).
3. Records the new cumulative total in the `billing_reports` ledger.
Re-running is always safe: the ledger makes repeats no-ops, a per-run lock
prevents overlapping executions, and a deterministic Stripe `identifier`
(`customer:period:billable`) dedupes crash retries.
### 6.2 One-time Stripe setup (test mode)
1. Dashboard (test mode ON) → **Billing → Meters → Create meter**:
event name `zappier.api_cents`, aggregation **Sum** of `value`,
customer mapping `stripe_customer_id`.
2. **Product catalog → Add product** "Zappier API usage" → price: recurring,
monthly, metered against that meter, **$0.01 per unit** (1 unit = 1 cent).
3. For each billable customer: create the Stripe Customer, attach a payment
method, and add a **subscription** with the metered price. Meter events for
customers without a metered subscription are recorded but never invoiced.
4. Put the `sk_test_...` key into `.env` (replace the placeholder).
5. Attach Stripe IDs to Zappier customers:
```bash
curl -X PUT -H 'x-admin-key: admin-dev-key' -H 'content-type: application/json' \
-d '{"stripeCustomerId":"cus_..."}' \
http://localhost:3000/admin/api/customers/cust_2
```
### 6.3 Verifying a run
```bash
npx ts-node src/jobs/report-usage.ts
```
Expected output per customer:
- `skip <id> <period> (nothing to report)` — no billable usage yet
- `skip <id> <period> (already reported Nc)` — no new usage since last run
- `<id>: reported N billable cents to Stripe` — delta sent
- `report-usage: another run holds the lock, abort run` — safe concurrent abort
Then check the meter's **Events** tab in the Stripe dashboard and the test
customer's **upcoming invoice**.
### 6.4 Going live
Repeat 6.2 steps 13 in live mode, replace `.env` with the `sk_live_...` key
from the **same Stripe account**, and keep the same cron. The live product
`prod_Uxv9SAeIOZzyx1` (currently deactivated) can be reactivated or recreated.
---
## 7. The Zapier integration
The `zapier-app/` directory contains the Zapier Platform app:
- **Authentication:** API key — the user pastes their API base URL
(e.g. `http://localhost:3000`) and their `key-...` customer key; the
connection is tested against `/v1/status`.
- **Trigger "New Item":** polls `GET /v1/storage` for newly stored items.
- **Action "Store Data":** calls `POST /v1/storage` with metadata and optional
file attachments — billed per the rate card.
To publish: create a Zapier developer account, `cd zapier-app && npm install
&& zapier login && zapier push`, then share the app or submit it to the Zapier
marketplace.
---
## 8. Day-to-day runbook
| Task | How |
|---|---|
| Change a price | Admin console → Rate card → Save |
| Add a customer | Admin console → Customers → Create → copy the one-time API key |
| Set a customer's billing type/email | Customers tab → Billing dropdown / Email field → Save |
| Invoice a period | Invoices tab → Generate → Issue → Mark paid (ACCOUNTING.md) |
| Export accounting data | Reports tab → filters → Download CSV |
| Give a customer a deal | Customers tab → multiplier override, or a new customer type |
| Turn an endpoint off | Rate card → Delete (calls get 403) |
| Check a customer's usage | Their portal dashboard, `GET /v1/usage` with their key, or query `zappier.db` |
| Check billing ran | Kimi notification after each 06:17 run; or run the job manually |
| Backup | Copy `zappier.db` (SQLite, single file) |
| Update dependencies | `npm outdated`, then `npm test` must stay green (165 tests) |
---
## 9. Troubleshooting
| Symptom | Likely cause | Fix |
|---|---|---|
| `401 invalid or missing API key` | Wrong/absent `x-api-key` | Re-issue key from Customers tab |
| `403 No price rule for ...` | Endpoint deleted from rate card and tier has no default rule | Re-add the rule, or set a tier `defaultRule` |
| `403 Unknown tier` | Customer's `tierId` doesn't exist | Fix the customer's type in the admin UI |
| `400 invalid metadata JSON` | `metadata` form field isn't valid JSON | Send e.g. `{"key":"value"}` |
| `413 ... file too large` | Attachment over 25 MB | Split or compress the file |
| Billing run: Stripe auth error | Placeholder/wrong key in `.env`, or key from a different Stripe account | Use the `sk_test`/`sk_live` key from the account that holds the meter |
| Stripe shows `METER_NOT_FOUND` invalid events | Meter missing or event name mismatch | Meter event name must be exactly `zappier.api_cents` |
| `another run holds the lock, abort run` | Overlapping runs, or a crash left a stale lock | Safe by design; stale locks expire after 1 hour |
| Admin UI: "invalid username or password" | Wrong credentials, or env overrides changed them | Check `ADMIN_KEY` / `DEMO_ADMIN_PASSWORD`; sign in again |
| Admin UI: "Session expired" | Server restarted (admin tokens are in-memory) | Sign in again |
| Portal login: `totp_required` | 2FA is enabled on the account | Enter the current 6-digit authenticator code |
| Portal: "invalid or expired session" | 7-day session expired | Sign in again |
| Portal reload didn't credit | Real `STRIPE_SECRET_KEY` configured → PaymentIntent awaits confirmation | Balance credits when the payment confirms; in dev (placeholder key) credit is instant |
| Invoice went straight to `paid` on Issue | Customer's prepaid balance fully covered the amount due | Working as designed — balance was drawn down |

BIN
docs/USER-MANUAL.pdf Normal file

Binary file not shown.

154
docs/WALKTHROUGH.md Normal file
View file

@ -0,0 +1,154 @@
# Zappier — Step-by-Step Usage Walkthrough
**Version:** 0.1.0 · **Last updated:** 2026-07-27
A full guided tour: from starting the server to pricing changes, customer
creation, and live API calls. Every step shows the real screen you should see.
Companion documents: [USER-MANUAL.md](USER-MANUAL.md) ·
[DEVELOPER.md](DEVELOPER.md).
---
## Step 1 — Start the server
```bash
cd /Users/marchon/zappier
npm install # first time only
npm run dev
```
Wait for the two "listening" lines:
![Start the server](walkthrough/01-start-server.png)
---
## Step 2 — Open the admin UI and sign in
Go to **http://localhost:3000/admin**. You'll see the sign-in screen:
![Sign in](walkthrough/02-login.png)
Two accounts are available:
| Username | Password | Purpose |
|---|---|---|
| `admin` | the `ADMIN_KEY` env value (default `admin-dev-key`) | Primary operator |
| `demo` | `$$$Adm1n###` | Demo / stakeholder access |
> Override either credential with the `ADMIN_USER`, `ADMIN_KEY`,
> `DEMO_ADMIN_USER`, and `DEMO_ADMIN_PASSWORD` environment variables.
---
## Step 3 — The rate card
After sign-in you land on the **Rate card** — one row per API endpoint with
its price in cents:
![Rate card](walkthrough/03-rate-card.png)
- `status`, `storage-list` — **free**
- `transform`**fixed** price per call
- `storage`**variable**: base + per-KB metadata + per-MB attachments
---
## Step 4 — Change a price
Edit any cent field — here `transform` is changed from **4¢ to 6¢** — and click
**Save**. The change is live on the very next API call; no restart, no deploy.
![Edit a price](walkthrough/04-edit-price.png)
After saving, the table re-reads from the server and shows the new value:
![Price saved](walkthrough/04b-saved-toast.png)
---
## Step 5 — Customer types
Click **Customer types** in the sidebar. Each type is a pricing tier:
a **multiplier** applied to every list price and a **monthly credit** of free
included usage (cents).
![Customer types](walkthrough/05-tiers.png)
---
## Step 6 — Add a customer type
Fill the **Add customer type** form — here `edu` / Education / ×0.6 — and click
**Add**. The new type appears immediately and can be assigned to customers.
![Add customer type](walkthrough/06-add-tier.png)
> New types start with 0 monthly credit — edit the row and **Save** to grant one.
---
## Step 7 — Customers
Click **Customers** in the sidebar. This is where accounts live: their type,
and an optional **multiplier override** for per-customer deals (blank =
inherit from type).
![Customers](walkthrough/07-customers.png)
---
## Step 8 — Create a customer and copy the API key
Enter a name, pick a type, click **Create**. The API key appears **once** in
the notification at the bottom-right — copy it and send it to the customer;
it is never shown again.
![API key shown once](walkthrough/08b-api-key-toast.png)
The new customer appears in the table right away:
![Customer created](walkthrough/08-create-customer.png)
---
## Step 9 — Explore the interactive API docs
Open **http://localhost:3000/docs** — full Swagger docs with a "Try it out"
console. Click **Authorize** and paste a customer API key to make live calls
from the browser.
![API docs](walkthrough/09-api-docs.png)
---
## Step 10 — Make an API call
Call the API with a customer key. Every priced response includes its **quote**,
so the cost of every call is transparent:
![API call with quote](walkthrough/10-api-call.png)
Note how the quote reflects the walkthrough itself: the 6¢ price set in step 4,
halved to 3¢ by Grace's Pro multiplier.
---
## Step 11 — Check usage and credits
Customers can check their own month-to-date usage anytime:
![Usage summary](walkthrough/11-usage.png)
`includedCents` is covered by the type's monthly credit; `billableCents` is
what the daily billing job would report to Stripe right now.
---
## Where to go next
- **Daily billing** runs automatically at 06:17 ET — see
[USER-MANUAL.md §6](USER-MANUAL.md#6-billing-operations-stripe) for the
Stripe meter/product/price setup and how to verify a run.
- **Troubleshooting:** [USER-MANUAL.md §9](USER-MANUAL.md#9-troubleshooting).
- **Internals:** [DEVELOPER.md](DEVELOPER.md).

BIN
docs/WALKTHROUGH.pdf Normal file

Binary file not shown.

115
docs/index.html Normal file
View file

@ -0,0 +1,115 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>Zappier — Documentation</title>
<style>
:root {
--bg: #f4f5fb;
--panel: #ffffff;
--ink: #171a26;
--muted: #6b7186;
--line: #e5e7f0;
--accent: #4f46e5;
--accent-soft: #eef0fe;
--radius: 12px;
--shadow: 0 1px 2px rgba(23, 26, 38, 0.05), 0 8px 24px rgba(23, 26, 38, 0.06);
}
* { box-sizing: border-box; }
body {
margin: 0;
font-family: -apple-system, "SF Pro Text", "Segoe UI", "PingFang SC", sans-serif;
background: var(--bg);
color: var(--ink);
font-size: 15px;
line-height: 1.5;
}
header {
background: linear-gradient(160deg, #312e81 0%, #4f46e5 55%, #7c74f0 100%);
color: #fff;
padding: 3rem 1.5rem 2.5rem;
text-align: center;
}
header .dot {
width: 44px; height: 44px; border-radius: 12px;
background: rgba(255, 255, 255, 0.16);
display: inline-grid; place-items: center;
font-weight: 800; font-size: 1.3rem; margin-bottom: 0.75rem;
}
header h1 { margin: 0 0 0.35rem; font-size: 1.7rem; }
header p { margin: 0; opacity: 0.85; }
main { max-width: 880px; margin: 0 auto; padding: 2rem 1.5rem 4rem; }
h2 { font-size: 0.8rem; text-transform: uppercase; letter-spacing: 0.06em; color: var(--muted); margin: 2rem 0 0.9rem; }
.cards { display: grid; grid-template-columns: repeat(auto-fill, minmax(260px, 1fr)); gap: 1rem; }
a.card {
display: block;
background: var(--panel);
border: 1px solid var(--line);
border-radius: var(--radius);
box-shadow: var(--shadow);
padding: 1.1rem 1.25rem;
text-decoration: none;
color: inherit;
transition: transform 0.12s ease, box-shadow 0.12s ease;
}
a.card:hover { transform: translateY(-2px); box-shadow: 0 4px 8px rgba(23,26,38,0.06), 0 16px 36px rgba(23,26,38,0.1); }
a.card h3 { margin: 0 0 0.35rem; font-size: 1.02rem; color: var(--accent); }
a.card p { margin: 0 0 0.7rem; color: var(--muted); font-size: 0.9rem; }
.fmt { display: inline-block; padding: 0.08rem 0.55rem; border-radius: 999px; font-size: 0.72rem; font-weight: 700; background: var(--accent-soft); color: var(--accent); margin-right: 0.3rem; }
.fmt.md { background: #f1f5f9; color: #475569; }
footer { text-align: center; color: var(--muted); font-size: 0.85rem; padding-bottom: 2rem; }
</style>
</head>
<body>
<header>
<span class="dot">Z</span>
<h1>Zappier Documentation</h1>
<p>Metered API platform — pricing, billing, invoicing, customer portal, and Zapier integration.</p>
</header>
<main>
<h2>Operations</h2>
<div class="cards">
<a class="card" href="USER-MANUAL.pdf">
<h3>Operations &amp; Usage Manual</h3>
<p>Running the system day to day: pricing, customers, billing operations, runbook, troubleshooting.</p>
<span class="fmt">PDF</span><span class="fmt">10 pages</span>
</a>
<a class="card" href="ACCOUNTING.pdf">
<h3>Company Accounting Walkthrough</h3>
<p>Invoices, purchase-order billing, prepaid drawdown, reports with CSV export, usage trends.</p>
<span class="fmt">PDF</span><span class="fmt">7 pages</span>
</a>
<a class="card" href="USER-MANAGEMENT.pdf">
<h3>User Management Walkthrough</h3>
<p>Admin accounts, rate card (free / fixed / variable pricing), customer types, customer accounts.</p>
<span class="fmt">PDF</span><span class="fmt">5 pages</span>
</a>
</div>
<h2>For your customers</h2>
<div class="cards">
<a class="card" href="CUSTOMER-PORTAL.pdf">
<h3>Customer Portal Walkthrough</h3>
<p>End-user guide: signup, two-factor authentication, usage dashboard, invoices, reloads, API &amp; pricing.</p>
<span class="fmt">PDF</span><span class="fmt">8 pages</span>
</a>
</div>
<h2>Technical</h2>
<div class="cards">
<a class="card" href="DEVELOPER.pdf">
<h3>Developer Documentation</h3>
<p>Architecture, module reference, pricing engine, billing pipeline, full API reference, extension guide.</p>
<span class="fmt">PDF</span><span class="fmt">9 pages</span>
</a>
<a class="card" href="WALKTHROUGH.pdf">
<h3>Step-by-Step Usage Walkthrough</h3>
<p>The original guided tour of the pricing admin UI with screenshots at every step.</p>
<span class="fmt">PDF</span><span class="fmt">13 pages</span>
</a>
</div>
</main>
<footer>Zappier · generated 2026-07-28</footer>
</body>
</html>

Binary file not shown.

After

Width:  |  Height:  |  Size: 85 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 40 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 124 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 124 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 401 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 76 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 91 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 59 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 64 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 69 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 60 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 78 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 57 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 59 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 71 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 42 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 52 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 398 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 50 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 402 KiB

BIN
docs/superpowers/.DS_Store vendored Normal file

Binary file not shown.

View file

@ -0,0 +1,163 @@
# Accounting, User Management & Customer Portal — Implementation Plan
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [x]`) syntax for tracking.
**Goal:** Extend Zappier with three surfaces: (a) company accounting & system management, (b) internal user management, and (c) an end-user customer portal — with full documentation and screenshot walkthroughs for each.
**Architecture:** Same ports-and-adapters style as the existing codebase. New domain modules (`src/invoicing.ts`, `src/reports.ts`, `src/accounts.ts`) expose pure logic + repo interfaces; SQLite adapters live in `src/db/`; HTTP wiring goes into `src/admin.ts` (company side) and a new `src/portal.ts` (customer side). The admin SPA gains tabs; the portal is a second dependency-free SPA under `portal/`. No new runtime deps in Phase 12; Phase 3 adds `otplib`-style TOTP only if unavoidable — prefer `node:crypto` scrypt for passwords and a minimal TOTP implementation per RFC 6238.
**Tech Stack:** Node 20 · TS strict · Express 4 · better-sqlite3 · jest/supertest.
## Global Constraints
- Money is integer cents everywhere; multipliers applied once, `Math.round`.
- Money/date logic must be pure functions, unit-tested without HTTP or SQLite.
- All SQL in `src/db/` repos only; all tables `CREATE TABLE IF NOT EXISTS`; schema changes for existing tables use idempotent `ALTER TABLE ... ADD COLUMN` guarded by `PRAGMA table_info` checks.
- Existing 87 jest tests + 4 mocha tests stay green; `npx tsc --noEmit` clean after every task.
- No servers left running after any verification; walkthrough screenshots use a throwaway `ZAPPIER_DB`.
- PDFs: Phase 12 deliver **print-ready HTML report/invoice pages** (browser Print → PDF, zero deps) plus CSV; binary PDF generation is deferred unless the owner asks.
- The demo password `$$$Adm1n###` and `admin-dev-key` remain dev defaults, overridable by env.
## Surface map
| Surface | URL | Auth | Audience |
|---|---|---|---|
| Admin SPA (extended) | `/admin` | login token / `x-admin-key` | Company ops & accounting |
| Customer portal SPA (new) | `/portal` | customer session token (+TOTP 2FA) | End-user customers |
| Public API | `/v1` | `x-api-key` | Unchanged |
---
## Phase 1 — Accounting backend
### Task 1: Schema + customer billing identity
**Files:**
- Modify: `src/auth.ts``Customer` gains `billingType: 'stripe' | 'purchase_order'` (default `'stripe'`), `email?: string`
- Modify: `src/db/customer-repo.ts` — idempotent `ALTER TABLE customers ADD COLUMN billing_type TEXT NOT NULL DEFAULT 'stripe'`; same for `email TEXT`; map in `toCustomer`
- Modify: `src/admin.ts``PUT /customers/:id` accepts `billingType` (validated) and `email`
- Test: `tests/db-customer.test.ts`, `tests/admin.test.ts` (extend)
**Interfaces:**
- Consumes: existing `CustomerRepo`
- Produces: `Customer.billingType`, `Customer.email` used by all later tasks
- [x] Extend `tests/db-customer.test.ts`: migration on existing DB adds columns with `'stripe'` default; round-trip `billingType`/`email`
- [x] Extend `tests/admin.test.ts`: `PUT /customers/:id` sets `billingType: 'purchase_order'`; invalid value → 400
- [x] Implement; run suites; commit `feat(accounting): customer billing types and email`
### Task 2: Invoice engine
**Files:**
- Create: `src/invoicing.ts` — types + pure generation logic + repo interface
- Create: `src/db/invoice-repo.ts` — SQLite adapter
- Test: `tests/invoicing.test.ts`, `tests/db-invoice.test.ts`
**Interfaces:**
```ts
export interface Invoice {
id: string; // INV-2026-07-<customerSeq>
customerId: string;
period: string; // YYYY-MM
status: 'draft' | 'issued' | 'paid';
lines: InvoiceLine[]; // { endpointId, calls, cents }
totalCents: number; // sum of lines (gross usage)
creditCents: number; // monthly credit applied
billableCents: number; // totalCents - creditCents, floored at 0
billingType: 'stripe' | 'purchase_order';
poNumber?: string; // PO billing only
issuedAtMs?: number; dueAtMs?: number; paidAtMs?: number;
}
export interface InvoiceRepo {
save(invoice: Invoice): void;
get(id: string): Invoice | undefined;
list(filter: { customerId?: string; period?: string; status?: Invoice['status'] }): Invoice[];
nextSequence(period: string): number;
}
export function buildInvoice(args: {
customer: Customer; period: string; sequence: number;
entries: UsageEntry[]; tier: TierConfig; poNumber?: string;
}): Invoice; // groups entries by endpoint; status 'draft'
```
- [x] Tests: line grouping, credit math (partial/zero/excess), id format, PO fields
- [x] Repo tests: save/get/list filters, sequence increments per period
- [x] Implement; commit `feat(accounting): invoice engine`
### Task 3: Reports service (billing + trends, CSV/JSON)
**Files:**
- Create: `src/reports.ts``billingRows(entries, customers, tiers, range)` → rows `{customerId, name, billingType, calls, totalCents, creditCents, billableCents}`; `usageTrend(entries, bucket: 'day'|'week')``[{bucket, calls, cents}]`; `toCsv(rows)` with RFC-4180 escaping
- Test: `tests/reports.test.ts`
- [x] Tests: date-range filtering (inclusive from, exclusive to), per-customer vs all, per-billingType filter, trend bucketing across month boundary, CSV quoting of commas/quotes/newlines
- [x] Implement; commit `feat(accounting): reports service`
### Task 4: Admin accounting API
**Files:**
- Modify: `src/admin.ts` — new routes (all behind existing admin auth):
- `POST /invoices/generate { period, customerId?, poNumber? }` → builds draft invoices for the period (all customers or one; idempotent per customer+period — regenerating replaces the draft)
- `POST /invoices/:id/issue`, `POST /invoices/:id/paid`
- `GET /invoices?customerId&period&status`
- `GET /invoices/:id` (+ `?format=html` print-ready invoice page)
- `GET /reports/billing?from&to&customerId&billingType&format=json|csv`
- `GET /reports/usage-trend?from&to&bucket&customerId`
- `GET /zapier/status``{ published: boolean, triggerCount, actionCount, baseUrl }` read from `zapier-app/` files (static inspection, no network)
- Test: `tests/admin-accounting.test.ts`
- [x] Tests per route incl. CSV content-type, filter combos, invoice lifecycle transitions (draft→issued→paid; illegal transitions → 409)
- [x] Implement; commit `feat(accounting): admin accounting API`
---
## Phase 2 — Admin UI: Accounting, Reports, Users tabs
### Task 5: Invoices tab (generate, filter, lifecycle, print page)
### Task 6: Reports tab (date-range pickers, customer + billing-type filters, CSV download, trend charts as inline SVG)
### Task 7: Users tab (admin account management: list/create/deactivate admin users backed by a new `admin_users` table replacing the static two-account map; login endpoint reads the table; env seed preserved)
### Task 8: System tab (Zapier connection status, billing job last-run info from `billing_reports`/`job_locks`)
Each: admin UI section + `tests/` coverage for any new API + screenshot verification. Commit per task.
---
## Phase 3 — Customer portal (`/portal`)
### Task 9: Customer identity
- `customers` += `password_hash`, `totp_secret`, `totp_enabled`, `email_verified`
- `src/accounts.ts`: scrypt hash/verify (`node:crypto`), session tokens (new `portal_sessions` table), signup `POST /portal/api/signup` (creates customer on `free` tier + issues API key), login `POST /portal/api/login`
- Tests: hash round-trip, signup/login flows, session expiry
### Task 10: TOTP 2FA
- RFC 6238 TOTP (HMAC-SHA1, 30 s step, 6 digits) implemented in `src/accounts.ts` (no dep): `generateTotpSecret`, `totpUri(secret, email)`, `verifyTotp(secret, code, window=1)`
- Routes: `POST /portal/api/2fa/setup` (returns secret + otpauth URI; QR rendered client-side via a tiny inline QR lib or Google-Charts-free canvas QR — decision: render otpauth URI as text + QR via `qrcode` npm dep, portal-side only), `POST /portal/api/2fa/enable`, `POST /portal/api/2fa/verify` (login second step), `POST /portal/api/2fa/disable`
- Tests: known RFC vectors, window tolerance, login requires second factor when enabled
### Task 11: Portal dashboard API
- `GET /portal/api/me` (profile, tier, apiKey, regenerate key `POST /portal/api/api-key`)
- `GET /portal/api/usage` (month-to-date + credit)
- `GET /portal/api/invoices` (own invoices only, scoped by session customer)
- `POST /portal/api/reload { amountCents }` — prepaid balance: `customers` += `balance_cents`; Stripe PaymentIntent via existing SDK (test mode); balance drawn down at invoice issue before metered reporting
- `PUT /portal/api/email-invoicing { enabled, email }` — stored prefs; billing job emails PO invoices (send via SMTP env config; dev: log-only transport)
- Tests: session scoping (cannot read other customers' invoices), reload math, key regeneration invalidates old key
### Task 12: Portal SPA
- `portal/index.html` + `portal/app.js` in the same design language as the new admin: login/signup/2FA screens, dashboard (usage + balance), invoices (history + print), billing (reload, email invoicing), API docs + pricing info pages (rendered from `openapi.yaml` + live rate card)
- Screenshot verification of every screen
---
## Phase 4 — Documentation
### Task 13: `docs/ACCOUNTING.md` + PDF — company accounting walkthrough (invoices, PO billing, reports, trends, CSV export) with screenshots
### Task 14: `docs/USER-MANAGEMENT.md` + PDF — admin users walkthrough
### Task 15: `docs/CUSTOMER-PORTAL.md` + PDF — end-user walkthrough (signup, 2FA, reload, invoices, API docs)
### Task 16: Refresh `USER-MANUAL.md`, `DEVELOPER.md`, `README.md`; re-export all PDFs
---
## Self-review notes
- Spec coverage: Stripe **and** PO billing (Tasks 12, 11), invoicing histories (2, 4, 5), CSV/PDF + date range + all/individual/by-type (3, 4, 6), usage/billing trends (3, 6), management reports (4, 6, 8), Zapier connections (4, 8), user management (7), customer login/setup/2FA/invoices/reload/email invoicing/API docs/pricing (912), docs for all three surfaces (1316).
- Type consistency: `Invoice`, `InvoiceRepo`, `billingRows`, `usageTrend`, `toCsv` signatures are defined once above and reused in every later task.

File diff suppressed because it is too large Load diff

Binary file not shown.

After

Width:  |  Height:  |  Size: 37 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 401 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 72 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 72 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 72 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 60 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 65 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 59 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 64 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 83 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 60 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 58 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 50 KiB

5
jest.config.js Normal file
View file

@ -0,0 +1,5 @@
module.exports = {
preset: 'ts-jest',
testEnvironment: 'node',
roots: ['<rootDir>/tests'],
};

144
openapi.yaml Normal file
View file

@ -0,0 +1,144 @@
openapi: 3.0.3
info:
title: Zappier API
version: 0.1.0
description: Metered data API. Every priced call returns its quote.
security:
- apiKey: []
paths:
/v1/status:
get:
operationId: status
summary: Service status (free)
responses:
'200':
description: OK
/v1/transform:
post:
operationId: transform
summary: Uppercase a string (fixed price)
requestBody:
required: true
content:
application/json:
schema:
type: object
required: [text]
properties:
text:
type: string
responses:
'200':
description: OK
/v1/storage:
post:
operationId: storage
summary: Store metadata and file attachments (variable price by size)
requestBody:
required: true
content:
multipart/form-data:
schema:
type: object
properties:
metadata:
type: string
description: JSON string of metadata
attachments:
type: array
items:
type: string
format: binary
responses:
'200':
description: OK
get:
operationId: storage-list
summary: List your stored items (free)
responses:
'200':
description: OK
/v1/timestamp:
post:
operationId: timestamp
summary: Register a timestamp (proxies middleware or in-process mock)
requestBody:
required: true
content:
application/json:
schema:
type: object
properties:
data:
type: string
sha256:
type: string
hashAlg:
type: string
responses:
'202':
description: Accepted
/v1/hashes/{sha256}:
get:
operationId: hash-lookup
summary: Lookup a SHA256 timestamp (mock)
parameters:
- name: sha256
in: path
required: true
schema:
type: string
responses:
'200':
description: OK
'404':
description: Missing
/v1/receipts/{jobId}:
get:
operationId: receipt
summary: Retrieval receipt JSON (mock job ids from /v1/timestamp)
parameters:
- name: jobId
in: path
required: true
schema:
type: string
responses:
'200':
description: OK
'404':
description: Missing
/v1/add:
post:
operationId: add
summary: Add two numbers (activate-now sample)
requestBody:
required: true
content:
application/json:
schema:
type: object
required: [number1, number2]
properties:
number1:
type: number
description: First addend
number2:
type: number
description: Second addend
responses:
'200':
description: OK
/v1/usage:
get:
operationId: usage
summary: Your usage summary for the current period
responses:
'200':
description: OK
components:
securitySchemes:
apiKey:
type: apiKey
in: header
name: x-api-key

6197
package-lock.json generated Normal file

File diff suppressed because it is too large Load diff

39
package.json Normal file
View file

@ -0,0 +1,39 @@
{
"name": "zappier",
"version": "0.1.0",
"private": true,
"scripts": {
"build": "tsc -p tsconfig.json",
"start": "node dist/index.js",
"dev": "ts-node src/index.ts",
"test": "jest"
},
"dependencies": {
"@types/qrcode": "^1.5.6",
"better-sqlite3": "^12.11.1",
"dotenv": "^17.4.2",
"express": "^4.19.2",
"express-openapi-validator": "^5.3.0",
"nats": "^2.29.3",
"qrcode": "^1.5.4",
"stripe": "^16.0.0",
"swagger-ui-express": "^5.0.0",
"yamljs": "^0.3.0"
},
"devDependencies": {
"@types/better-sqlite3": "^7.6.13",
"@types/dotenv": "^6.1.1",
"@types/express": "^4.17.21",
"@types/jest": "^29.5.12",
"@types/multer": "^1.4.11",
"@types/node": "^20.14.0",
"@types/supertest": "^6.0.2",
"@types/swagger-ui-express": "^4.1.6",
"@types/yamljs": "^0.2.34",
"jest": "^29.7.0",
"supertest": "^7.0.0",
"ts-jest": "^29.1.4",
"ts-node": "^10.9.2",
"typescript": "^5.5.0"
}
}

424
portal/app.js Normal file
View file

@ -0,0 +1,424 @@
const state = { me: null, usage: null, invoices: [], pricing: null, statement: null };
const TOKEN_KEY = 'zappier-portal-token';
let authMode = 'login'; // 'login' | 'signup'
/* ---------------- auth ---------------- */
function token() {
return localStorage.getItem(TOKEN_KEY);
}
function showAuth(message = '') {
document.getElementById('shell').classList.remove('on');
document.getElementById('auth').style.display = 'grid';
document.getElementById('auth-error').textContent = message;
}
function showShell() {
document.getElementById('auth').style.display = 'none';
document.getElementById('shell').classList.add('on');
}
function setAuthMode(mode) {
authMode = mode;
const isSignup = mode === 'signup';
document.getElementById('name-group').style.display = isSignup ? 'block' : 'none';
document.getElementById('totp-group').style.display = 'none';
document.getElementById('auth-sub').textContent = isSignup
? 'Create your customer account — you get an API key immediately.'
: 'Sign in to your customer account.';
document.getElementById('auth-submit').textContent = isSignup ? 'Create account' : 'Sign in';
document.getElementById('auth-switch').innerHTML = isSignup
? 'Already have an account? <a id="auth-toggle">Sign in</a>'
: 'New here? <a id="auth-toggle">Create an account</a>';
document.getElementById('auth-error').textContent = '';
document
.getElementById('auth-toggle')
.addEventListener('click', () => setAuthMode(isSignup ? 'login' : 'signup'));
}
document.getElementById('auth-toggle').addEventListener('click', () => setAuthMode('signup'));
document.getElementById('auth-form').addEventListener('submit', async (e) => {
e.preventDefault();
const email = document.getElementById('auth-email').value.trim();
const password = document.getElementById('auth-password').value;
const totpCode = document.getElementById('auth-totp').value.trim();
try {
const path = authMode === 'signup' ? '/portal/api/signup' : '/portal/api/login';
const body =
authMode === 'signup'
? { name: document.getElementById('auth-name').value.trim(), email, password }
: { email, password, ...(totpCode ? { totpCode } : {}) };
const res = await fetch(path, {
method: 'POST',
headers: { 'content-type': 'application/json' },
body: JSON.stringify(body),
});
const data = await res.json();
if (!res.ok) {
if (data.error === 'totp_required') {
document.getElementById('totp-group').style.display = 'block';
document.getElementById('auth-totp').focus();
throw new Error('Enter the 6-digit code from your authenticator app.');
}
throw new Error(data.error || 'Authentication failed');
}
localStorage.setItem(TOKEN_KEY, data.token);
showShell();
load().catch((err) => say(err.message, true));
} catch (err) {
document.getElementById('auth-error').textContent = err.message;
}
});
document.getElementById('logout').addEventListener('click', async () => {
try {
await api('/logout', { method: 'POST', body: '{}' });
} catch {
/* session already gone */
}
localStorage.removeItem(TOKEN_KEY);
location.reload();
});
/* ---------------- api + status ---------------- */
async function api(path, options = {}) {
const res = await fetch(`/portal/api${path}`, {
...options,
headers: { 'content-type': 'application/json', authorization: `Bearer ${token()}` },
});
if (res.status === 401) {
localStorage.removeItem(TOKEN_KEY);
showAuth('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 EMPTY_SVG =
'<svg width="80" height="64" viewBox="0 0 80 64" fill="none" aria-hidden="true"><rect x="16" y="8" width="48" height="48" rx="8" fill="#eef0fe"/><rect x="24" y="20" width="32" height="4" rx="2" fill="#4f46e5" opacity=".35"/><rect x="24" y="30" width="24" height="4" rx="2" fill="#4f46e5" opacity=".2"/><rect x="24" y="40" width="28" height="4" rx="2" fill="#4f46e5" opacity=".2"/></svg>';
function maskKey(k) {
if (!k || k.length < 8) return '••••••••';
return `${k.slice(0, 4)}••••••••${k.slice(-4)}`;
}
async function load() {
state.me = await api('/me');
state.usage = await api('/usage');
state.invoices = (await api('/invoices')).invoices;
state.pricing = await api('/pricing');
state.statement = await api('/statement');
renderDashboard();
renderInvoices();
renderStatement();
renderBilling();
renderSecurity();
renderDocs();
}
/* ---------------- dashboard ---------------- */
function renderDashboard() {
const u = state.usage;
const me = state.me;
const tier = state.pricing.tiers.find((t) => t.id === me.tierId);
document.getElementById('dashboard').innerHTML = `
<h2>Welcome, ${me.name}</h2>
<p class="lede">${me.email} · ${tier ? tier.name : me.tierId} plan · ${me.billingType === 'stripe' ? 'Stripe billing' : 'Purchase-order billing'}</p>
<div class="stat-grid">
<div class="stat"><div class="k">Usage this month</div><div class="v">${fmt(u.totalCents)}</div></div>
<div class="stat"><div class="k">Included credit</div><div class="v">${fmt(u.includedCents)}</div></div>
<div class="stat"><div class="k">Billable</div><div class="v">${fmt(u.billableCents)}</div></div>
<div class="stat"><div class="k">Prepaid balance</div><div class="v">${fmt(me.balanceCents)}</div></div>
</div>
<div class="card">
<h3>Your API key</h3>
<div class="apikey-row">
<code id="api-key" data-key="${me.apiKey}" data-masked="1">${maskKey(me.apiKey)}</code>
<button class="btn ghost" type="button" onclick="toggleKey()">Reveal</button>
<button class="btn ghost" type="button" onclick="copyKey()">Copy</button>
<button class="btn" type="button" onclick="regenerateKey()">Regenerate</button>
</div>
<p class="hint">Send it as the <span class="id">x-api-key</span> header. The key stays masked on screen. Copy puts the full key on the clipboard. Regenerating invalidates the old key immediately. Per-endpoint usage: ${Object.entries(u.byEndpoint ?? {}).map(([k, v]) => `${k}: ${v.calls} calls`).join(', ') || 'none yet this month'}.</p>
</div>`;
}
function toggleKey() {
const el = document.getElementById('api-key');
const btn = el.parentElement.querySelector('button');
const masked = el.dataset.masked === '1';
el.textContent = masked ? el.dataset.key : maskKey(el.dataset.key);
el.dataset.masked = masked ? '0' : '1';
btn.textContent = masked ? 'Hide' : 'Reveal';
}
function copyKey() {
navigator.clipboard.writeText(document.getElementById('api-key').dataset.key);
say('API key copied.');
}
async function regenerateKey() {
const res = await api('/api-key', { method: 'POST', body: '{}' });
state.me.apiKey = res.apiKey;
renderDashboard();
say('New API key issued — the old key no longer works.');
}
/* ---------------- invoices ---------------- */
function renderInvoices() {
const rows = state.invoices
.slice()
.sort((a, b) => b.id.localeCompare(a.id))
.map(
(inv) => `<tr>
<td class="id">${inv.id}</td>
<td>${inv.period}</td>
<td><span class="pill ${inv.status}">${inv.status}</span></td>
<td class="money">${fmt(inv.totalCents)}</td>
<td class="money">${fmt(inv.creditCents)}</td>
<td class="money"><b>${fmt(inv.billableCents)}</b></td>
<td>${fmtDate(inv.dueAtMs)}</td>
<td class="row-actions"><button class="btn ghost" onclick="viewInvoice('${inv.id}')">View / print</button></td>
</tr>`,
)
.join('');
document.getElementById('invoices').innerHTML = `
<h2>Invoices</h2>
<p class="lede">Your invoice history. View / print opens a print-ready page use the browsers Print Save as PDF.</p>
<div class="card">${
rows
? `<table>
<thead><tr><th>Invoice</th><th>Period</th><th>Status</th><th>Total</th><th>Credit</th><th>Amount due</th><th>Due date</th><th></th></tr></thead>
<tbody>${rows}</tbody>
</table>`
: `<div class="empty">${EMPTY_SVG}<h3>No invoices yet</h3><p>After the first billing period is generated, invoices appear here. Use View / print to save a PDF.</p></div>`
}</div>`;
}
async function viewInvoice(id) {
const res = await fetch(`/portal/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');
}
/* ---------------- statement ---------------- */
function renderStatement() {
const st = state.statement || { prepaidCents: 0, credits: [], usage: [], payments: [] };
const emptyCard = (title) =>
`<div class="empty">${EMPTY_SVG}<h3>${title}</h3><p>Nothing recorded yet.</p></div>`;
const row = (list, cols) => {
if (!list || !list.length) return '';
return list
.map(
(r) =>
`<tr>${cols
.map((c) => {
const v = c === 'cents' ? fmt(r.cents) : c === 'at' ? fmtDate(r.at) : (r[c] ?? '—');
return `<td class="${c === 'cents' ? 'money' : ''}">${v}</td>`;
})
.join('')}</tr>`,
)
.join('');
};
document.getElementById('statement').innerHTML = `
<h2>Statement</h2>
<p class="lede">Prepaid balance, customer-service credits, metered usage, and payments. ${st.source === 'nats' ? 'Live from account-balance over NATS.' : 'From this portals ledger.'}</p>
<div class="stat-grid">
<div class="stat"><div class="k">Prepaid balance</div><div class="v">${fmt(st.prepaidCents || 0)}</div></div>
<div class="stat"><div class="k">Credits</div><div class="v">${st.credits?.length || 0}</div></div>
<div class="stat"><div class="k">Usage events</div><div class="v">${st.usage?.length || 0}</div></div>
<div class="stat"><div class="k">Payments</div><div class="v">${st.payments?.length || 0}</div></div>
</div>
<div class="card"><h3>Credits</h3>${
st.credits?.length
? `<table><thead><tr><th>Amount</th><th>Reason</th><th>Agent</th><th>When</th></tr></thead><tbody>${row(st.credits, ['cents', 'reason', 'agent', 'at'])}</tbody></table>`
: emptyCard('No credits yet')
}</div>
<div class="card"><h3>Usage</h3>${
st.usage?.length
? `<table><thead><tr><th>Endpoint</th><th>Amount</th><th>When</th></tr></thead><tbody>${row(st.usage, ['endpointId', 'cents', 'at'])}</tbody></table>`
: emptyCard('No usage yet')
}</div>
<div class="card"><h3>Payments</h3>${
st.payments?.length
? `<table><thead><tr><th>Amount</th><th>Kind</th><th>Reason</th><th>When</th></tr></thead><tbody>${row(st.payments, ['cents', 'kind', 'reason', 'at'])}</tbody></table>`
: emptyCard('No payments yet')
}</div>`;
}
/* ---------------- billing ---------------- */
function renderBilling() {
const me = state.me;
document.getElementById('billing').innerHTML = `
<h2>Billing</h2>
<p class="lede">Prepaid balance is drawn down automatically when an invoice is issued.</p>
<div class="card">
<h3>Reload balance <span class="pill on" style="margin-left:0.4rem">${fmt(me.balanceCents)}</span></h3>
<div class="filterbar">
<label><span>Amount (USD)</span><input id="reload-amount" type="number" min="1" max="10000" step="1" value="25"></label>
<button class="btn" onclick="reload()">Add funds</button>
</div>
<p class="hint">Minimum $1, maximum $10,000 per reload. In the development environment funds are credited instantly; with Stripe configured youll complete payment securely.</p>
</div>
<div class="card">
<h3>Email invoicing</h3>
<p class="hint" style="margin-top:0">Send a copy of each issued invoice to <b>${me.email}</b>.</p>
<label style="display:flex;align-items:center;gap:0.5rem;margin-top:0.5rem">
<input type="checkbox" id="email-invoicing" ${me.emailInvoicing ? 'checked' : ''} onchange="toggleEmailInvoicing(this.checked)">
Email me new invoices
</label>
</div>`;
}
async function reload() {
const dollars = Number(document.getElementById('reload-amount').value);
const amountCents = Math.round(dollars * 100);
const res = await api('/reload', { method: 'POST', body: JSON.stringify({ amountCents }) });
state.me.balanceCents = res.balanceCents;
renderBilling();
if (res.mode === 'dev') say(`Balance reloaded — new balance ${fmt(res.balanceCents)}.`);
else say('Payment started — your balance updates when the payment confirms.');
}
async function toggleEmailInvoicing(enabled) {
await api('/email-invoicing', { method: 'PUT', body: JSON.stringify({ enabled }) });
state.me.emailInvoicing = enabled;
say(enabled ? 'Email invoicing enabled.' : 'Email invoicing disabled.');
}
/* ---------------- security ---------------- */
function renderSecurity() {
const me = state.me;
document.getElementById('security').innerHTML = `
<h2>Security</h2>
<p class="lede">Two-factor authentication protects your account with a time-based code from an authenticator app.</p>
<div class="card">
<h3>Two-factor authentication <span class="pill ${me.totpEnabled ? 'on' : 'off'}" style="margin-left:0.4rem">${me.totpEnabled ? 'enabled' : 'disabled'}</span></h3>
<div id="totp-area">
${
me.totpEnabled
? `<p class="hint" style="margin-top:0">To disable 2FA, enter the current code from your authenticator app.</p>
<div class="filterbar">
<label><span>Authenticator code</span><input id="totp-disable-code" inputmode="numeric" placeholder="123456"></label>
<button class="btn ghost" onclick="disableTotp()">Disable 2FA</button>
</div>`
: `<p class="hint" style="margin-top:0">2FA is off. Set it up to require a code at sign-in.</p>
<button class="btn" onclick="setupTotp()">Set up 2FA</button>
<div id="totp-setup"></div>`
}
</div>
</div>
<div class="card">
<h3>Password</h3>
<p class="hint" style="margin:0">Password changes are handled by support for now sign out everywhere by signing back in (old sessions expire after 7 days).</p>
</div>`;
}
async function setupTotp() {
const res = await api('/2fa/setup', { method: 'POST', body: '{}' });
document.getElementById('totp-setup').innerHTML = `
<div class="qr-box">
<img src="${res.qr}" alt="2FA QR code" width="160" height="160">
<div>
<p style="margin:0 0 0.5rem"><b>1.</b> Scan with your authenticator app (or enter the secret manually):</p>
<p class="secret">${res.secret}</p>
<p style="margin:0.8rem 0 0.4rem"><b>2.</b> Enter the 6-digit code it shows:</p>
<div class="filterbar">
<label><span>Code</span><input id="totp-enable-code" inputmode="numeric" placeholder="123456"></label>
<button class="btn" onclick="enableTotp()">Enable 2FA</button>
</div>
</div>
</div>`;
}
async function enableTotp() {
const code = document.getElementById('totp-enable-code').value.trim();
await api('/2fa/enable', { method: 'POST', body: JSON.stringify({ code }) });
state.me.totpEnabled = true;
renderSecurity();
say('Two-factor authentication is now on.');
}
async function disableTotp() {
const code = document.getElementById('totp-disable-code').value.trim();
await api('/2fa/disable', { method: 'POST', body: JSON.stringify({ code }) });
state.me.totpEnabled = false;
renderSecurity();
say('Two-factor authentication disabled.');
}
/* ---------------- docs + pricing ---------------- */
function renderDocs() {
const rows = Object.entries(state.pricing.rateCard.endpoints)
.map(([id, rule]) => {
const price =
rule.kind === 'free'
? 'Free'
: rule.kind === 'fixed'
? `${rule.fixedCents}¢ / call`
: `${rule.baseCents}¢ + ${rule.perKbCents}¢/KB + ${rule.perMbCents}¢/MB`;
return `<tr><td class="id">${id}</td><td><span class="pill ${rule.kind}">${rule.kind}</span></td><td>${price}</td></tr>`;
})
.join('');
const tiers = state.pricing.tiers
.map(
(t) =>
`<tr><td class="id">${t.id}</td><td>${t.name}</td><td class="money">${t.multiplier}×</td><td class="money">${fmt(t.monthlyCreditCents)}</td></tr>`,
)
.join('');
document.getElementById('docs').innerHTML = `
<h2>API &amp; pricing</h2>
<p class="lede">Interactive API reference lives at <a href="/docs" target="_blank" style="color:var(--accent)">/docs</a>. That page is stock Swagger UI not a designed customer surface. Use your API key as <span class="id">x-api-key</span>.</p>
<div class="card">
<h3>Rate card (list prices)</h3>
<table><thead><tr><th>Endpoint</th><th>Kind</th><th>List price</th></tr></thead><tbody>${rows}</tbody></table>
</div>
<div class="card">
<h3>Plans</h3>
<table><thead><tr><th>Id</th><th>Name</th><th>Price multiplier</th><th>Monthly credit</th></tr></thead><tbody>${tiers}</tbody></table>
<p class="hint">Your plans multiplier scales list prices; the monthly credit is free included usage. Youre on <b>${state.me.tierId}</b>.</p>
</div>`;
}
/* ---------------- 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 {
showAuth();
}

282
portal/index.html Normal file
View file

@ -0,0 +1,282 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>Zappier Portal</title>
<link rel="icon" href="data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 32 32'%3E%3Crect width='32' height='32' rx='8' fill='%234f46e5'/%3E%3C/svg%3E"/>
<style>
:root {
--bg: #f4f5fb;
--panel: #ffffff;
--ink: #171a26;
--muted: #6b7186;
--line: #e5e7f0;
--accent: #4f46e5;
--accent-ink: #ffffff;
--accent-soft: #eef0fe;
--danger: #dc2626;
--ok: #047857;
--radius: 12px;
--shadow: 0 1px 2px rgba(23, 26, 38, 0.05), 0 8px 24px rgba(23, 26, 38, 0.06);
}
* { box-sizing: border-box; }
body {
margin: 0;
font-family: -apple-system, "SF Pro Text", "Segoe UI", "PingFang SC", sans-serif;
background: var(--bg);
color: var(--ink);
font-size: 14px;
}
/* ---------- Auth ---------- */
#auth {
min-height: 100vh;
display: grid;
place-items: center;
background: linear-gradient(160deg, #312e81 0%, #4f46e5 55%, #7c74f0 100%);
}
#auth .card {
width: 380px;
background: var(--panel);
border-radius: 16px;
box-shadow: 0 24px 64px rgba(17, 12, 60, 0.35);
padding: 2rem;
}
#auth h1 { font-size: 1.35rem; margin: 0 0 0.25rem; }
#auth p.sub { color: var(--muted); margin: 0 0 1.25rem; }
#auth label { display: block; font-weight: 600; font-size: 0.8rem; margin: 0.9rem 0 0.3rem; }
#auth input {
width: 100%;
padding: 0.6rem 0.75rem;
border: 1px solid var(--line);
border-radius: 8px;
font-size: 0.95rem;
}
#auth input:focus { outline: 2px solid var(--accent); border-color: transparent; }
#auth button.primary {
width: 100%;
margin-top: 1.4rem;
padding: 0.65rem;
border: 0;
border-radius: 8px;
background: var(--accent);
color: var(--accent-ink);
font-weight: 700;
font-size: 0.95rem;
cursor: pointer;
}
#auth button.primary:hover { filter: brightness(1.08); }
#auth-error { color: var(--danger); font-size: 0.85rem; min-height: 1.2em; margin: 0.6rem 0 0; }
#auth-switch { margin: 1rem 0 0; font-size: 0.85rem; color: var(--muted); text-align: center; }
#auth-switch a { color: var(--accent); cursor: pointer; font-weight: 600; text-decoration: none; }
#totp-group { display: none; }
/* ---------- Shell ---------- */
#shell { display: none; min-height: 100vh; }
#shell.on { display: grid; grid-template-columns: 232px 1fr; }
aside {
background: var(--panel);
border-right: 1px solid var(--line);
padding: 1.25rem 0.9rem;
display: flex;
flex-direction: column;
gap: 0.25rem;
position: sticky;
top: 0;
height: 100vh;
}
.brand { display: flex; align-items: center; gap: 0.6rem; padding: 0.25rem 0.6rem 1.1rem; }
.brand .dot {
width: 30px; height: 30px; border-radius: 9px;
background: linear-gradient(140deg, var(--accent), #8b85f2);
display: grid; place-items: center; color: #fff; font-weight: 800;
}
.brand b { font-size: 1.02rem; }
nav button {
display: flex; align-items: center; gap: 0.55rem;
width: 100%;
border: 0; background: none;
text-align: left;
padding: 0.55rem 0.7rem;
border-radius: 8px;
font-size: 0.92rem;
color: var(--muted);
cursor: pointer;
}
nav button { min-height: 40px; }
nav button:hover { background: var(--bg); color: var(--ink); }
nav button.active { background: var(--accent-soft); color: var(--accent); font-weight: 700; }
nav button:focus-visible, #logout:focus-visible, button.btn:focus-visible, a:focus-visible {
outline: 2px solid var(--accent); outline-offset: 2px;
}
.skip { position:absolute; left:-999px; }
.skip:focus { left:1rem; top:1rem; z-index:30; background:#fff; color:var(--accent); padding:.5rem .9rem; border-radius:8px; }
aside .spacer { flex: 1; }
#logout {
border: 1px solid var(--line); background: none; border-radius: 8px;
padding: 0.5rem; color: var(--muted); cursor: pointer; font-size: 0.85rem;
}
#logout:hover { color: var(--danger); border-color: var(--danger); }
main { padding: 1.75rem 2rem 3rem; max-width: 1080px; }
main h2 { margin: 0 0 0.25rem; font-size: 1.3rem; }
main .lede { color: var(--muted); margin: 0 0 1.25rem; }
.card {
background: var(--panel);
border: 1px solid var(--line);
border-radius: var(--radius);
box-shadow: var(--shadow);
padding: 1.1rem 1.25rem;
margin-bottom: 1.25rem;
}
.card h3 { margin: 0 0 0.9rem; font-size: 0.95rem; }
table { border-collapse: collapse; width: 100%; }
th {
text-align: left; font-size: 0.72rem; text-transform: uppercase;
letter-spacing: 0.04em; color: var(--muted);
border-bottom: 1px solid var(--line); padding: 0.45rem 0.6rem;
}
td { border-bottom: 1px solid var(--line); padding: 0.55rem 0.6rem; }
tr:last-child td { border-bottom: 0; }
tbody tr:hover { background: #fafaff; }
td.id, .id { font-family: "SF Mono", Menlo, monospace; font-size: 0.82rem; font-weight: 600; }
input, select {
padding: 0.4rem 0.55rem;
border: 1px solid var(--line);
border-radius: 7px;
font-size: 0.88rem;
background: #fff;
color: var(--ink);
}
input:focus, select:focus { outline: 2px solid var(--accent); border-color: transparent; }
button.btn {
border: 0; border-radius: 7px; padding: 0.42rem 0.85rem;
font-size: 0.85rem; font-weight: 600; cursor: pointer;
background: var(--accent); color: var(--accent-ink);
}
button.btn:hover { filter: brightness(1.08); }
button.btn.ghost { background: none; border: 1px solid var(--line); color: var(--muted); }
button.btn.ghost:hover { color: var(--accent); border-color: var(--accent); }
.row-actions { white-space: nowrap; text-align: right; }
.row-actions button { margin-left: 0.35rem; }
.pill {
display: inline-block; padding: 0.1rem 0.55rem; border-radius: 999px;
font-size: 0.72rem; font-weight: 700;
}
.pill.draft { background: #f1f5f9; color: #475569; }
.pill.issued { background: #fff7ed; color: #c2410c; }
.pill.paid { background: #ecfdf5; color: var(--ok); }
.pill.free { background: #ecfdf5; color: var(--ok); }
.pill.fixed { background: var(--accent-soft); color: var(--accent); }
.pill.variable { background: #fff7ed; color: #c2410c; }
.pill.on { background: #ecfdf5; color: var(--ok); }
.pill.off { background: #f1f5f9; color: #475569; }
.stat-grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(150px, 1fr)); gap: 0.9rem; margin-bottom: 1.25rem; }
.stat {
background: var(--panel); border: 1px solid var(--line);
border-radius: var(--radius); box-shadow: var(--shadow);
padding: 0.9rem 1.1rem;
}
.stat .k { font-size: 0.72rem; text-transform: uppercase; letter-spacing: 0.05em; color: var(--muted); }
.stat .v { font-size: 1.35rem; font-weight: 800; margin-top: 0.2rem; font-variant-numeric: tabular-nums; }
.money { font-variant-numeric: tabular-nums; text-align: right; }
.kv { display: grid; grid-template-columns: 170px 1fr; row-gap: 0.5rem; align-items: center; }
.kv dt { color: var(--muted); font-size: 0.82rem; }
.kv dd { margin: 0; font-weight: 600; }
.apikey-row { display: flex; gap: 0.5rem; align-items: center; flex-wrap: wrap; }
.apikey-row code {
background: var(--bg); border: 1px solid var(--line); border-radius: 7px;
padding: 0.45rem 0.7rem; font-size: 0.85rem; flex: 1; min-width: 12rem;
font-family: "SF Mono", Menlo, monospace; letter-spacing: 0.04em;
}
.empty { text-align:center; padding:1.6rem 1rem; color:var(--muted); }
.empty svg { display:block; margin:0 auto .6rem; }
.empty h3 { margin:0 0 .25rem; color:var(--ink); font-size:1rem; }
.qr-box { display: flex; gap: 1.25rem; align-items: flex-start; margin: 0.8rem 0; }
.qr-box img { border: 1px solid var(--line); border-radius: 10px; }
.qr-box .secret {
font-family: "SF Mono", Menlo, monospace; font-size: 0.9rem; font-weight: 700;
letter-spacing: 0.06em; background: var(--bg); padding: 0.4rem 0.7rem; border-radius: 7px;
}
.hint { color: var(--muted); font-size: 0.82rem; margin-top: 0.8rem; }
.filterbar { display: flex; flex-wrap: wrap; align-items: end; gap: 0.8rem; }
.filterbar label { display: flex; flex-direction: column; gap: 0.25rem; }
.filterbar label span {
font-size: 0.72rem; text-transform: uppercase; letter-spacing: 0.04em;
color: var(--muted); font-weight: 600;
}
#status {
position: fixed; right: 1.25rem; bottom: 1.25rem;
background: var(--ink); color: #fff;
padding: 0.7rem 1.1rem; border-radius: 10px;
box-shadow: var(--shadow);
font-size: 0.88rem;
opacity: 0; transform: translateY(8px);
transition: opacity 0.2s, transform 0.2s;
max-width: 420px;
pointer-events: none;
}
#status.show { opacity: 1; transform: none; }
#status.error { background: var(--danger); }
</style>
</head>
<body>
<a class="skip" href="#main">Skip to content</a>
<section id="auth">
<form class="card" id="auth-form">
<h1>Zappier Portal</h1>
<p class="sub" id="auth-sub">Sign in to your customer account.</p>
<div id="name-group" style="display:none">
<label for="auth-name">Name</label>
<input id="auth-name" autocomplete="name" />
</div>
<label for="auth-email">Email</label>
<input id="auth-email" type="email" autocomplete="email" required />
<label for="auth-password">Password</label>
<input id="auth-password" type="password" autocomplete="current-password" required />
<div id="totp-group">
<label for="auth-totp">Authenticator code</label>
<input id="auth-totp" inputmode="numeric" placeholder="123456" />
</div>
<p id="auth-error"></p>
<button type="submit" class="primary" id="auth-submit">Sign in</button>
<p id="auth-switch">New here? <a id="auth-toggle">Create an account</a></p>
</form>
</section>
<div id="shell">
<aside>
<div class="brand"><span class="dot">Z</span><b>Zappier</b></div>
<nav>
<button data-tab="dashboard" class="active">&nbsp; Dashboard</button>
<button data-tab="invoices">&nbsp; Invoices</button>
<button data-tab="statement">&nbsp; Statement</button>
<button data-tab="billing">&nbsp; Billing</button>
<button data-tab="security">&nbsp; Security</button>
<button data-tab="docs">&nbsp; API &amp; pricing</button>
</nav>
<div class="spacer"></div>
<button id="logout">Sign out</button>
</aside>
<main id="main">
<section id="dashboard"></section>
<section id="invoices" hidden></section>
<section id="statement" hidden></section>
<section id="billing" hidden></section>
<section id="security" hidden></section>
<section id="docs" hidden></section>
</main>
</div>
<p id="status"></p>
<script src="app.js"></script>
</body>
</html>

36
src/accounting-export.ts Normal file
View file

@ -0,0 +1,36 @@
import { Invoice } from './invoicing';
function iifDate(ms: number): string {
const d = new Date(ms);
return `${String(d.getUTCMonth() + 1).padStart(2, '0')}/${String(d.getUTCDate()).padStart(2, '0')}/${d.getUTCFullYear()}`;
}
/** QuickBooks IIF journal for issued/paid invoices (Accounts Receivable + Sales). */
export function invoicesToQuickBooksIif(invoices: Invoice[]): string {
const lines = [
'!TRNS\tTRNSID\tTRNSTYPE\tDATE\tACCNT\tNAME\tAMOUNT\tDOCNUM',
'!SPL\tSPLID\tTRNSTYPE\tDATE\tACCNT\tNAME\tAMOUNT\tDOCNUM',
'!ENDTRNS',
];
for (const inv of invoices) {
if (inv.status === 'draft') continue;
const date = iifDate(inv.issuedAtMs || inv.paidAtMs || Date.now());
const dollars = (inv.billableCents / 100).toFixed(2);
const name = inv.customerId;
lines.push(`TRNS\t\tINVOICE\t${date}\tAccounts Receivable\t${name}\t${dollars}\t${inv.id}`);
lines.push(`SPL\t\tINVOICE\t${date}\tSales\t${name}\t-${dollars}\t${inv.id}`);
lines.push('ENDTRNS');
}
return lines.join('\n') + '\n';
}
export function invoicesToAccountingCsv(invoices: Invoice[]): string {
const header = 'invoice_id,customer_id,period,status,billing_type,total_cents,credit_cents,billable_cents,po_number';
const rows = invoices.map(
(i) =>
[i.id, i.customerId, i.period, i.status, i.billingType, i.totalCents, i.creditCents, i.billableCents, i.poNumber || ''].join(
',',
),
);
return [header, ...rows].join('\n') + '\n';
}

165
src/accounts.ts Normal file
View file

@ -0,0 +1,165 @@
import { createHmac, randomBytes, scryptSync, timingSafeEqual } from 'crypto';
/**
* Customer-portal identity primitives: password hashing (scrypt), TOTP
* two-factor secrets (RFC 6238), and portal sessions. Pure functions plus
* repo interfaces; SQLite adapters live in src/db/.
*/
/* ---------------- password hashing (scrypt) ---------------- */
const SCRYPT_N = 16384;
const SCRYPT_R = 8;
const SCRYPT_P = 1;
const KEY_LEN = 32;
/** Format: scrypt:N:r:p:<salt b64>:<hash b64> */
export function hashPassword(password: string): string {
const salt = randomBytes(16);
const hash = scryptSync(password, salt, KEY_LEN, {
N: SCRYPT_N,
r: SCRYPT_R,
p: SCRYPT_P,
});
return `scrypt:${SCRYPT_N}:${SCRYPT_R}:${SCRYPT_P}:${salt.toString('base64')}:${hash.toString('base64')}`;
}
export function verifyPassword(password: string, stored: string): boolean {
const parts = stored.split(':');
if (parts.length !== 6 || parts[0] !== 'scrypt') return false;
const [, n, r, p, saltB64, hashB64] = parts;
const expected = Buffer.from(hashB64, 'base64');
if (expected.length === 0) return false;
const actual = scryptSync(password, Buffer.from(saltB64, 'base64'), expected.length, {
N: Number(n),
r: Number(r),
p: Number(p),
});
return timingSafeEqual(actual, expected);
}
/* ---------------- base32 (RFC 4648, no padding) ---------------- */
const B32_ALPHABET = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ234567';
export function base32Encode(buf: Buffer): string {
let bits = 0;
let value = 0;
let out = '';
for (const byte of buf) {
value = (value << 8) | byte;
bits += 8;
while (bits >= 5) {
out += B32_ALPHABET[(value >>> (bits - 5)) & 31];
bits -= 5;
}
}
if (bits > 0) out += B32_ALPHABET[(value << (5 - bits)) & 31];
return out;
}
export function base32Decode(s: string): Buffer {
let bits = 0;
let value = 0;
const out: number[] = [];
for (const ch of s.toUpperCase().replace(/=+$/, '')) {
const idx = B32_ALPHABET.indexOf(ch);
if (idx < 0) throw new Error(`invalid base32 character: ${ch}`);
value = (value << 5) | idx;
bits += 5;
if (bits >= 8) {
out.push((value >>> (bits - 8)) & 0xff);
bits -= 8;
}
}
return Buffer.from(out);
}
/* ---------------- TOTP (RFC 6238, HMAC-SHA1, 30 s step, 6 digits) ---------------- */
export function hotp(secret: string, counter: number, digits = 6): string {
const key = base32Decode(secret);
const msg = Buffer.alloc(8);
msg.writeBigUInt64BE(BigInt(counter));
const h = createHmac('sha1', key).update(msg).digest();
const offset = h[h.length - 1] & 0x0f;
const code =
(((h[offset] & 0x7f) << 24) |
(h[offset + 1] << 16) |
(h[offset + 2] << 8) |
h[offset + 3]) %
10 ** digits;
return String(code).padStart(digits, '0');
}
export function totp(secret: string, atMs: number, stepSec = 30, digits = 6): string {
return hotp(secret, Math.floor(atMs / 1000 / stepSec), digits);
}
export function verifyTotp(
secret: string,
code: string,
atMs: number,
window = 1,
): boolean {
if (!/^\d{6}$/.test(code)) return false;
for (let w = -window; w <= window; w++) {
if (totp(secret, atMs + w * 30_000) === code) return true;
}
return false;
}
/** 160-bit secret, base32 without padding (authenticator-app standard). */
export function generateTotpSecret(): string {
return base32Encode(randomBytes(20));
}
export function totpUri(secret: string, email: string, issuer = 'Zappier'): string {
return `otpauth://totp/${encodeURIComponent(issuer)}:${encodeURIComponent(email)}?secret=${secret}&issuer=${encodeURIComponent(issuer)}`;
}
/* ---------------- portal sessions ---------------- */
export interface PortalSession {
token: string;
customerId: string;
createdMs: number;
expiresMs: number;
}
export interface SessionRepo {
create(customerId: string, ttlMs: number): PortalSession;
/** Returns the session, or undefined when unknown or expired at nowMs. */
get(token: string, nowMs?: number): PortalSession | undefined;
delete(token: string): void;
}
export function newSessionToken(): string {
return randomBytes(24).toString('hex');
}
export class InMemorySessionRepo implements SessionRepo {
private sessions = new Map<string, PortalSession>();
create(customerId: string, ttlMs: number): PortalSession {
const now = Date.now();
const session: PortalSession = {
token: newSessionToken(),
customerId,
createdMs: now,
expiresMs: now + ttlMs,
};
this.sessions.set(session.token, session);
return session;
}
get(token: string, nowMs = Date.now()): PortalSession | undefined {
const s = this.sessions.get(token);
if (!s || s.expiresMs <= nowMs) return undefined;
return s;
}
delete(token: string): void {
this.sessions.delete(token);
}
}

75
src/admin-users.ts Normal file
View file

@ -0,0 +1,75 @@
import { randomBytes } from 'crypto';
import { hashPassword } from './accounts';
/**
* Admin-console accounts. Replaces the original static two-account env map
* with a persisted table; the env values remain the seed for empty databases.
*/
export interface AdminUser {
id: string; // usr_<hex>
username: string;
passwordHash: string;
active: boolean;
createdMs: number;
}
export interface AdminUserRepo {
list(): AdminUser[];
findByUsername(username: string): AdminUser | undefined;
save(user: AdminUser): void;
}
export function newAdminUserId(): string {
return `usr_${randomBytes(6).toString('hex')}`;
}
/** The env-backed seed accounts (preserved from the pre-table behavior). */
export function seedAdminUsersFromEnv(): { username: string; password: string }[] {
return [
{
username: process.env.ADMIN_USER ?? 'admin',
password: process.env.ADMIN_KEY ?? 'admin-dev-key',
},
{
username: process.env.DEMO_ADMIN_USER ?? 'demo',
password: process.env.DEMO_ADMIN_PASSWORD ?? '$$$Adm1n###',
},
];
}
export function makeAdminUser(username: string, password: string): AdminUser {
return {
id: newAdminUserId(),
username,
passwordHash: hashPassword(password),
active: true,
createdMs: Date.now(),
};
}
export class InMemoryAdminUserRepo implements AdminUserRepo {
private users: AdminUser[];
private constructor(users: AdminUser[]) {
this.users = users.map((u) => ({ ...u }));
}
static seeded(seed: { username: string; password: string }[]): InMemoryAdminUserRepo {
return new InMemoryAdminUserRepo(seed.map((s) => makeAdminUser(s.username, s.password)));
}
list(): AdminUser[] {
return [...this.users];
}
findByUsername(username: string): AdminUser | undefined {
return this.users.find((u) => u.username === username);
}
save(user: AdminUser): void {
const i = this.users.findIndex((u) => u.id === user.id || u.username === user.username);
if (i >= 0) this.users[i] = user;
else this.users.push(user);
}
}

649
src/admin.ts Normal file
View file

@ -0,0 +1,649 @@
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();
}
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;
}

287
src/app.ts Normal file
View file

@ -0,0 +1,287 @@
import path from 'path';
import { createHash, randomUUID } from 'crypto';
import express, { Express, NextFunction, Request, RequestHandler, Response } from 'express';
import * as OpenApiValidator from 'express-openapi-validator';
import swaggerUi from 'swagger-ui-express';
import YAML from 'yamljs';
import {
apiKeyAuth,
Customer,
CustomerRepo,
InMemoryCustomerRepo,
} from './auth';
import { meter } from './meter';
import { applyMonthlyCredit } from './billing/credit';
import {
ConfigTierCatalog,
InMemoryPricingStore,
PricingContext,
PricingStore,
} from './pricing';
import { InMemoryUsageRepo, UsageRepo } from './usage';
import { adminAuth, adminLoginRouter, adminPerms, adminRouter } from './admin';
import { AdminUserRepo, InMemoryAdminUserRepo, seedAdminUsersFromEnv } from './admin-users';
import { InMemorySessionRepo, SessionRepo } from './accounts';
import { InMemoryInvoiceRepo, InvoiceRepo } from './invoicing';
import { PROJECT_ROOT } from './paths';
import { PaymentClient, portalRouter } from './portal';
import { proxyVerae } from './upstream';
import { CreditLedger } from './credits';
import { composeStatement } from './statement';
import { BILLING_SUBJECTS, natsAdjust, natsPublish, natsStatement } from './billing-nats';
export interface StoredItem {
id: string;
customerId: string;
metadata: Record<string, unknown>;
attachments: { filename: string; size: number }[];
createdAt: string;
}
export const DEFAULT_CUSTOMERS: Customer[] = [
{ id: 'cust_1', name: 'Ada (free)', tierId: 'free', apiKey: 'key-ada' },
{ id: 'cust_2', name: 'Grace (pro)', tierId: 'pro', apiKey: 'key-grace' },
{ id: 'cust_3', name: 'Linus (business)', tierId: 'business', apiKey: 'key-linus' },
];
export interface AppDeps {
usage?: UsageRepo;
customers?: CustomerRepo;
pricingStore?: PricingStore;
invoices?: InvoiceRepo;
adminUsers?: AdminUserRepo;
sessions?: SessionRepo;
payments?: PaymentClient;
/** QR renderer for 2FA setup; defaults to the qrcode package. */
qr?: (uri: string) => Promise<string>;
}
const SPEC_PATH = path.join(PROJECT_ROOT, 'openapi.yaml');
const parseMetadata: RequestHandler = (req, res, next) => {
const raw = req.body?.metadata;
if (raw === undefined || raw === null || raw === '') {
res.locals.parsedMetadata = {};
return next();
}
if (typeof raw !== 'string') {
res.locals.parsedMetadata = raw;
return next();
}
try {
res.locals.parsedMetadata = JSON.parse(raw);
next();
} catch {
res.status(400).json({ error: 'invalid metadata JSON' });
}
};
export function buildApp(deps: AppDeps = {}): {
app: Express;
usage: UsageRepo;
customers: CustomerRepo;
pricing: PricingContext;
pricingStore: PricingStore;
invoices: InvoiceRepo;
items: StoredItem[];
} {
const customers = deps.customers ?? new InMemoryCustomerRepo(DEFAULT_CUSTOMERS);
const usage = deps.usage ?? new InMemoryUsageRepo();
const pricingStore = deps.pricingStore ?? new InMemoryPricingStore();
const invoices = deps.invoices ?? new InMemoryInvoiceRepo();
const adminUsers =
deps.adminUsers ?? InMemoryAdminUserRepo.seeded(seedAdminUsersFromEnv());
// Live pricing context: every quote reads the store, so admin edits apply immediately.
const pricing: PricingContext = {
get rateCard() {
return pricingStore.getRateCard();
},
get tiers() {
return new ConfigTierCatalog(pricingStore.getTiers());
},
};
const items: StoredItem[] = [];
const credits = new CreditLedger();
const onUsage = (e: { customerId: string; endpointId: string; cents: number }) => {
const c = customers.list().find((row) => row.id === e.customerId);
natsPublish(
BILLING_SUBJECTS.USAGE_RECORDED,
{ ...e, veraeUserId: c?.veraeUserId, at: new Date().toISOString() },
'api',
);
};
const hashIndex = new Map<
string,
{ jobId: string; sha256: string; data?: string; timestamp: string }
>();
const jobIndex = new Map<string, { jobId: string; sha256: string; data?: string; timestamp: string }>();
const app = express();
app.use(express.json());
const spec = YAML.load(SPEC_PATH);
app.use(
'/docs',
swaggerUi.serve,
swaggerUi.setup(spec, {
customSiteTitle: 'OpenAPI explorer (stock Swagger UI)',
customCss:
'body::before{content:"OpenAPI explorer — stock Swagger UI for integrators. Not a customer surface. Use x-api-key.";' +
'display:block;background:#312e81;color:#eef0fe;font:650 13px -apple-system,sans-serif;padding:.7rem 1.2rem}',
}),
);
app.use(
'/admin/api',
adminLoginRouter(adminUsers),
adminAuth(),
adminPerms(),
adminRouter(pricingStore, customers, { usage, invoices, users: adminUsers }, credits),
);
app.use('/admin', express.static(path.join(PROJECT_ROOT, 'admin')));
const sessions = deps.sessions ?? new InMemorySessionRepo();
const qr =
deps.qr ??
(async (uri: string) => {
const qrcode = await import('qrcode');
return qrcode.toDataURL(uri, { margin: 1, width: 220 });
});
const payments: PaymentClient = deps.payments ?? {
// Safe default for tests/dev: credits immediately, no Stripe call.
reload: async (_customer, amountCents) => ({ mode: 'dev', creditedCents: amountCents }),
};
app.use(
'/portal/api',
portalRouter({
customers,
sessions,
usage,
invoices,
tiers: () => pricingStore.getTiers(),
rateCard: () => pricingStore.getRateCard(),
payments,
qr,
credits,
}),
);
app.use('/portal', express.static(path.join(PROJECT_ROOT, 'portal')));
app.get('/health', (_req, res) => {
res.json({ ok: true, role: 'zappier-edge' });
});
app.use('/v1', apiKeyAuth(customers));
app.use(
OpenApiValidator.middleware({
apiSpec: SPEC_PATH,
validateRequests: true,
validateResponses: false,
fileUploader: { limits: { fileSize: 25 * 1024 * 1024 } },
}),
);
app.get('/v1/status', meter('status', usage, pricing, onUsage), (req, res) => {
res.json({ status: 'ok', quote: res.locals.quote });
});
app.post('/v1/transform', meter('transform', usage, pricing, onUsage), (req, res) => {
const text = String(req.body?.text ?? '');
res.json({ output: text.toUpperCase(), quote: res.locals.quote });
});
app.post('/v1/timestamp', meter('timestamp', usage, pricing, onUsage), async (req, res) => {
if (await proxyVerae(req, res, '/zapier/v1/timestamp')) return;
const data = req.body?.data != null ? String(req.body.data) : '';
const sha256 =
(req.body?.sha256 && String(req.body.sha256).toLowerCase()) ||
(data ? createHash('sha256').update(data, 'utf8').digest('hex') : '');
if (!sha256) {
res.status(400).json({ error: 'data or sha256 is required' });
return;
}
const existing = hashIndex.get(sha256);
if (existing) {
res.status(202).json({ jobId: existing.jobId, sha256, existing: true, timestamp: existing.timestamp });
return;
}
const jobId = randomUUID();
const timestamp = new Date().toISOString();
const rec = { jobId, sha256, data, timestamp };
hashIndex.set(sha256, rec);
jobIndex.set(jobId, rec);
res.status(202).json({ jobId, sha256, existing: false, timestamp });
});
app.get('/v1/receipts/:jobId', meter('receipt', usage, pricing, onUsage), async (req, res) => {
if (await proxyVerae(req, res, `/zapier/v1/receipts/${req.params.jobId}`)) return;
const rec = jobIndex.get(String(req.params.jobId));
if (!rec) {
res.status(404).json({ error: 'Job not found' });
return;
}
res.json({
type: 'verae.retrieval-receipt',
format: 'json',
jobId: rec.jobId,
sha256: rec.sha256,
timestamp: rec.timestamp,
extraSeal: { event: 'document.retrieved', retrievedAt: new Date().toISOString() },
quote: res.locals.quote,
});
});
app.get('/v1/hashes/:sha256', meter('hash-lookup', usage, pricing, onUsage), async (req, res) => {
if (await proxyVerae(req, res, `/zapier/v1/hashes/${req.params.sha256}`)) return;
const sha256 = String(req.params.sha256 || '').toLowerCase();
const rec = hashIndex.get(sha256);
if (!rec) {
res.status(404).json({ error: 'Hash not found' });
return;
}
res.json({ exists: true, ...rec });
});
app.post('/v1/add', meter('add', usage, pricing, onUsage), (req, res) => {
const number1 = Number(req.body?.number1);
const number2 = Number(req.body?.number2);
if (!Number.isFinite(number1) || !Number.isFinite(number2)) {
res.status(400).json({ error: 'number1 and number2 must be finite numbers' });
return;
}
res.json({ number1, number2, sum: number1 + number2, quote: res.locals.quote });
});
app.post('/v1/storage', parseMetadata, meter('storage', usage, pricing, onUsage), (req, res) => {
const metadata = res.locals.parsedMetadata as Record<string, unknown>;
const files = (req.files as Express.Multer.File[]) ?? [];
const item: StoredItem = {
id: randomUUID(),
customerId: req.customer!.id,
metadata,
attachments: files.map((f) => ({ filename: f.originalname, size: f.size })),
createdAt: new Date().toISOString(),
};
items.unshift(item);
res.json({ id: item.id, quote: res.locals.quote });
});
app.get('/v1/storage', meter('storage-list', usage, pricing, onUsage), (req, res) => {
res.json({ items: items.filter((i) => i.customerId === req.customer!.id) });
});
app.get('/v1/usage', (req, res) => {
const since = new Date();
since.setUTCDate(1);
since.setUTCHours(0, 0, 0, 0);
const summary = usage.summaryFor(req.customer!.id, since);
const tier = pricing.tiers.find(req.customer!.tierId);
res.json(tier ? applyMonthlyCredit(summary, tier) : summary);
});
app.use((err: Error & { status?: number }, req: Request, res: Response, next: NextFunction) => {
res.status(err.status ?? 500).json({ error: err.message });
});
return { app, usage, customers, pricing, pricingStore, invoices, items };
}

86
src/auth.ts Normal file
View file

@ -0,0 +1,86 @@
import { RequestHandler } from 'express';
export type BillingType = 'stripe' | 'purchase_order';
export interface Customer {
id: string;
name: string;
tierId: string;
apiKey: string;
stripeCustomerId?: string;
multiplierOverride?: number;
/** How this customer is billed. Absent means 'stripe' (the default). */
billingType?: BillingType;
email?: string;
/** Stable Verae central user id (not a JWT). */
veraeUserId?: string;
veraeUsername?: string;
/** scrypt hash for portal login. Absent = no portal password set yet. */
passwordHash?: string;
/** Base32 TOTP secret. Present once 2FA setup begins. */
totpSecret?: string;
/** True once the customer has confirmed a TOTP code. */
totpEnabled?: boolean;
/** Prepaid balance in cents, drawn down when invoices are issued. */
balanceCents?: number;
/** Email a copy of each issued invoice to the customer's address. */
emailInvoicing?: boolean;
}
export interface CustomerRepo {
findByApiKey(apiKey: string): Customer | undefined;
findByEmail(email: string): Customer | undefined;
list(): Customer[];
save(customer: Customer): void;
}
export class InMemoryCustomerRepo implements CustomerRepo {
private customers: Customer[];
constructor(customers: Customer[] = []) {
// Defensive copy: save() replaces elements, and callers often pass shared
// seed arrays (DEFAULT_CUSTOMERS) that must not be mutated across tests.
this.customers = customers.map((c) => ({ ...c }));
}
findByApiKey(apiKey: string): Customer | undefined {
return this.customers.find((c) => c.apiKey === apiKey);
}
findByEmail(email: string): Customer | undefined {
const needle = email.toLowerCase();
return this.customers.find((c) => c.email?.toLowerCase() === needle);
}
list(): Customer[] {
return [...this.customers];
}
save(customer: Customer): void {
const i = this.customers.findIndex((c) => c.id === customer.id);
if (i >= 0) this.customers[i] = customer;
else this.customers.push(customer);
}
}
declare global {
// eslint-disable-next-line @typescript-eslint/no-namespace
namespace Express {
interface Request {
customer?: Customer;
}
}
}
export function apiKeyAuth(repo: CustomerRepo): RequestHandler {
return (req, res, next) => {
const key = req.header('x-api-key');
const customer = key ? repo.findByApiKey(key) : undefined;
if (!customer) {
res.status(401).json({ error: 'invalid or missing API key' });
return;
}
req.customer = customer;
next();
};
}

139
src/billing-nats.ts Normal file
View file

@ -0,0 +1,139 @@
/** Internal NATS billing bus. No-op when NATS_URL is unset (tests).
* When NATS is on, every hop must pass verae.access.authz.check for a plane.
*/
export const BILLING_SUBJECTS = {
STATEMENT_GET: 'verae.billing.statement.get',
BALANCE_ADJUST: 'verae.billing.balance.adjust',
USAGE_RECORDED: 'verae.billing.usage.recorded',
PAYMENT_RECORDED: 'verae.billing.payment.recorded',
CREDIT_APPLIED: 'verae.billing.credit.applied',
CUSTOMER_PUT: 'verae.billing.customer.put',
};
export const AUTHZ_CHECK = 'verae.access.authz.check';
export type AccessPlane = 'zapier' | 'web' | 'api' | 'leaf' | 'staff';
export type BillingStatement = {
customerId: string;
name?: string;
veraeUserId?: string;
prepaidCents: number;
credits: unknown[];
usage: unknown[];
payments: unknown[];
};
type Nc = {
request: (s: string, d: Uint8Array, o: { timeout: number }) => Promise<{ data: Uint8Array }>;
publish: (s: string, d: Uint8Array) => void;
};
let ncPromise: Promise<Nc | null> | null = null;
async function nc(): Promise<Nc | null> {
const url = process.env.NATS_URL;
if (!url) return null;
if (!ncPromise) {
ncPromise = (async () => {
try {
const nats = await import('nats');
return (await nats.connect({ servers: url.split(','), name: 'zappier-edge' })) as unknown as Nc;
} catch {
return null;
}
})();
}
return ncPromise;
}
function encode(obj: unknown): Uint8Array {
return new TextEncoder().encode(JSON.stringify(obj));
}
function decode(buf: Uint8Array): unknown {
return JSON.parse(new TextDecoder().decode(buf) || '{}');
}
async function authzAllow(
plane: AccessPlane,
subject: string,
extra: { principal?: string; kind?: string; veraeUserId?: string } = {},
): Promise<boolean> {
const http = process.env.AUTHZ_URL;
if (!http && !(await nc())) return true;
try {
const c = await nc();
if (c) {
const m = await c.request(AUTHZ_CHECK, encode({ plane, subject, ...extra }), { timeout: 1500 });
const d = decode(m.data) as { allow?: boolean };
return Boolean(d.allow);
}
if (http) {
const r = await fetch(`${http.replace(/\/$/, '')}/check`, {
method: 'POST',
headers: { 'content-type': 'application/json' },
body: JSON.stringify({ plane, subject, ...extra }),
});
const d = (await r.json()) as { allow?: boolean };
return Boolean(d.allow);
}
} catch {
return false;
}
return true;
}
export async function natsStatement(
customerId: string,
plane: AccessPlane = 'web',
veraeUserId?: string,
): Promise<BillingStatement | null> {
const c = await nc();
if (!c) return null;
if (
!(await authzAllow(plane, BILLING_SUBJECTS.STATEMENT_GET, { principal: customerId, veraeUserId }))
) {
return null;
}
const m = await c.request(
BILLING_SUBJECTS.STATEMENT_GET,
encode({ customerId, plane, veraeUserId }),
{ timeout: 2000 },
);
return decode(m.data) as BillingStatement;
}
export async function natsAdjust(
payload: {
customerId: string;
veraeUserId?: string;
cents: number;
reason: string;
agent: string;
kind?: string;
},
plane: AccessPlane = 'staff',
): Promise<unknown | null> {
const c = await nc();
if (!c) return null;
if (
!(await authzAllow(plane, BILLING_SUBJECTS.BALANCE_ADJUST, {
principal: payload.agent,
kind: payload.kind,
veraeUserId: payload.veraeUserId,
}))
) {
return null;
}
const m = await c.request(BILLING_SUBJECTS.BALANCE_ADJUST, encode({ ...payload, plane }), { timeout: 2000 });
return decode(m.data);
}
export function natsPublish(subject: string, payload: unknown, plane: AccessPlane = 'api'): void {
void (async () => {
const c = await nc();
if (!c) return;
if (!(await authzAllow(plane, subject))) return;
c.publish(subject, encode({ ...(payload as object), plane }));
})();
}

19
src/billing/credit.ts Normal file
View file

@ -0,0 +1,19 @@
import { TierConfig } from '../pricing';
import { UsageSummary } from '../usage';
export interface BilledSummary extends UsageSummary {
includedCents: number;
billableCents: number;
}
export function applyMonthlyCredit(
summary: UsageSummary,
tier: TierConfig,
): BilledSummary {
const includedCents = Math.min(summary.totalCents, tier.monthlyCreditCents);
return {
...summary,
includedCents,
billableCents: summary.totalCents - includedCents,
};
}

31
src/billing/reload.ts Normal file
View file

@ -0,0 +1,31 @@
import Stripe from 'stripe';
import { PaymentClient } from '../portal';
/**
* Stripe-backed reloads: creates a PaymentIntent and returns its client
* secret. The balance is credited only after the payment confirms (webhook
* step); until then creditedCents is 0 and the intent is pending.
*/
export function stripePaymentClient(secretKey: string): PaymentClient {
const stripe = new Stripe(secretKey);
return {
reload: async (customer, amountCents) => {
const intent = await stripe.paymentIntents.create({
amount: amountCents,
currency: 'usd',
automatic_payment_methods: { enabled: true },
metadata: { customerId: customer.id },
});
return {
mode: 'stripe',
creditedCents: 0,
clientSecret: intent.client_secret ?? undefined,
};
},
};
}
/** True when the key looks usable (not empty / not the setup placeholder). */
export function hasRealStripeKey(key: string | undefined): key is string {
return typeof key === 'string' && key.startsWith('sk_');
}

41
src/billing/stripe.ts Normal file
View file

@ -0,0 +1,41 @@
import { UsageEntry } from '../usage';
export const METER_EVENT_NAME = 'zappier.api_cents';
export interface MeterEventClient {
createMeterEvent(params: {
eventName: string;
customerId: string;
value: string;
/** Stripe-side dedup key; events with the same identifier are dropped. */
identifier?: string;
}): Promise<void>;
}
export function computeBillableCents(entries: UsageEntry[], monthlyCreditCents: number): number {
const totalCents = entries.reduce((sum, e) => sum + e.cents, 0);
return Math.max(0, totalCents - monthlyCreditCents);
}
export function computeDelta(billableCents: number, previouslyReportedCents: number): number {
return Math.max(0, billableCents - previouslyReportedCents);
}
// Public API: retained for API compatibility. The monthly billing job now uses
// the delta-based path in src/jobs/report-usage.ts (reportMonthlyUsage), which
// shares computeBillableCents with this function. Do not remove.
export async function reportUsage(
client: MeterEventClient,
stripeCustomerId: string,
entries: UsageEntry[],
monthlyCreditCents: number,
): Promise<number> {
const billable = computeBillableCents(entries, monthlyCreditCents);
if (billable <= 0) return 0;
await client.createMeterEvent({
eventName: METER_EVENT_NAME,
customerId: stripeCustomerId,
value: String(billable),
});
return billable;
}

29
src/credits.ts Normal file
View file

@ -0,0 +1,29 @@
export interface CreditAdjustment {
id: string;
customerId: string;
cents: number;
reason: string;
agent: string;
at: string;
}
export class CreditLedger {
private rows: CreditAdjustment[] = [];
add(row: Omit<CreditAdjustment, 'id' | 'at'> & { id?: string; at?: string }): CreditAdjustment {
const rec: CreditAdjustment = {
id: row.id || `crd_${Date.now().toString(36)}`,
customerId: row.customerId,
cents: row.cents,
reason: row.reason,
agent: row.agent,
at: row.at || new Date().toISOString(),
};
this.rows.unshift(rec);
return rec;
}
list(customerId?: string): CreditAdjustment[] {
return customerId ? this.rows.filter((r) => r.customerId === customerId) : this.rows;
}
}

71
src/db/admin-user-repo.ts Normal file
View file

@ -0,0 +1,71 @@
import Database from 'better-sqlite3';
import { AdminUser, AdminUserRepo, makeAdminUser } from '../admin-users';
/** SQLite-backed admin accounts; seeds from env only when the table is empty. */
export class SqliteAdminUserRepo implements AdminUserRepo {
private constructor(private db: Database.Database) {
this.db.exec(`
CREATE TABLE IF NOT EXISTS admin_users (
id TEXT PRIMARY KEY,
username TEXT NOT NULL UNIQUE,
password_hash TEXT NOT NULL,
active INTEGER NOT NULL DEFAULT 1,
created_ms INTEGER NOT NULL
)
`);
}
static seeded(
db: Database.Database,
seed: { username: string; password: string }[],
): SqliteAdminUserRepo {
const repo = new SqliteAdminUserRepo(db);
const { n } = db.prepare('SELECT COUNT(*) AS n FROM admin_users').get() as { n: number };
if (n === 0) {
for (const s of seed) repo.save(makeAdminUser(s.username, s.password));
}
return repo;
}
list(): AdminUser[] {
const rows = this.db
.prepare('SELECT * FROM admin_users ORDER BY username')
.all() as Record<string, unknown>[];
return rows.map(toAdminUser);
}
findByUsername(username: string): AdminUser | undefined {
const r = this.db.prepare('SELECT * FROM admin_users WHERE username = ?').get(username) as
| Record<string, unknown>
| undefined;
return r ? toAdminUser(r) : undefined;
}
save(user: AdminUser): void {
this.db
.prepare(
`INSERT INTO admin_users (id, username, password_hash, active, created_ms)
VALUES (@id, @username, @passwordHash, @active, @createdMs)
ON CONFLICT(username) DO UPDATE SET
password_hash = @passwordHash,
active = @active`,
)
.run({
id: user.id,
username: user.username,
passwordHash: user.passwordHash,
active: user.active ? 1 : 0,
createdMs: user.createdMs,
});
}
}
function toAdminUser(r: Record<string, unknown>): AdminUser {
return {
id: r.id as string,
username: r.username as string,
passwordHash: r.password_hash as string,
active: (r.active as number) === 1,
createdMs: r.created_ms as number,
};
}

68
src/db/billing-repo.ts Normal file
View file

@ -0,0 +1,68 @@
import Database from 'better-sqlite3';
export interface BillingReportRepo {
getReportedCents(customerId: string, period: string): number;
upsertReportedCents(customerId: string, period: string, cumulativeCents: number): void;
}
export interface JobLockRepo {
tryAcquireLock(name: string, ttlMs: number): boolean;
releaseLock(name: string): void;
}
export class SqliteBillingReportRepo implements BillingReportRepo, JobLockRepo {
constructor(
private db: Database.Database,
private now: () => number = Date.now,
) {
this.db.exec(`
CREATE TABLE IF NOT EXISTS billing_reports (
customer_id TEXT NOT NULL,
period TEXT NOT NULL,
reported_cents INTEGER NOT NULL,
reported_at_ms INTEGER NOT NULL,
PRIMARY KEY (customer_id, period)
)
`);
this.db.exec(`
CREATE TABLE IF NOT EXISTS job_locks (
name TEXT PRIMARY KEY,
acquired_at_ms INTEGER NOT NULL
)
`);
}
getReportedCents(customerId: string, period: string): number {
const row = this.db
.prepare('SELECT reported_cents FROM billing_reports WHERE customer_id = ? AND period = ?')
.get(customerId, period) as { reported_cents: number } | undefined;
return row?.reported_cents ?? 0;
}
upsertReportedCents(customerId: string, period: string, cumulativeCents: number): void {
this.db
.prepare(
`INSERT INTO billing_reports (customer_id, period, reported_cents, reported_at_ms)
VALUES (?, ?, ?, ?)
ON CONFLICT(customer_id, period) DO UPDATE SET
reported_cents = excluded.reported_cents,
reported_at_ms = excluded.reported_at_ms`,
)
.run(customerId, period, cumulativeCents, this.now());
}
tryAcquireLock(name: string, ttlMs: number): boolean {
const result = this.db
.prepare(
`INSERT INTO job_locks (name, acquired_at_ms) VALUES (?, ?)
ON CONFLICT(name) DO UPDATE SET acquired_at_ms = excluded.acquired_at_ms
WHERE job_locks.acquired_at_ms <= excluded.acquired_at_ms - ?`,
)
.run(name, this.now(), ttlMs);
return result.changes === 1;
}
releaseLock(name: string): void {
this.db.prepare('DELETE FROM job_locks WHERE name = ?').run(name);
}
}

112
src/db/customer-repo.ts Normal file
View file

@ -0,0 +1,112 @@
import Database from 'better-sqlite3';
import { Customer, CustomerRepo } from '../auth';
export class SqliteCustomerRepo implements CustomerRepo {
constructor(private db: Database.Database, seed: Customer[] = []) {
this.db.exec(`
CREATE TABLE IF NOT EXISTS customers (
id TEXT PRIMARY KEY,
name TEXT NOT NULL,
tier_id TEXT NOT NULL,
api_key TEXT NOT NULL UNIQUE,
stripe_customer_id TEXT,
multiplier_override REAL
)
`);
// Idempotent migrations for pre-existing databases.
this.ensureColumn('billing_type', `billing_type TEXT NOT NULL DEFAULT 'stripe'`);
this.ensureColumn('email', 'email TEXT');
this.ensureColumn('password_hash', 'password_hash TEXT');
this.ensureColumn('totp_secret', 'totp_secret TEXT');
this.ensureColumn('totp_enabled', 'totp_enabled INTEGER NOT NULL DEFAULT 0');
this.ensureColumn('balance_cents', 'balance_cents INTEGER NOT NULL DEFAULT 0');
this.ensureColumn('email_invoicing', 'email_invoicing INTEGER NOT NULL DEFAULT 0');
const { n } = this.db.prepare('SELECT COUNT(*) AS n FROM customers').get() as { n: number };
if (n === 0) {
for (const c of seed) this.save(c);
}
}
private ensureColumn(name: string, ddl: string): void {
const cols = this.db.prepare('PRAGMA table_info(customers)').all() as { name: string }[];
if (!cols.some((c) => c.name === name)) {
this.db.exec(`ALTER TABLE customers ADD COLUMN ${ddl}`);
}
}
findByApiKey(apiKey: string): Customer | undefined {
const r = this.db.prepare('SELECT * FROM customers WHERE api_key = ?').get(apiKey) as
| Record<string, unknown>
| undefined;
return r ? toCustomer(r) : undefined;
}
findByEmail(email: string): Customer | undefined {
const r = this.db
.prepare('SELECT * FROM customers WHERE lower(email) = lower(?)')
.get(email) as Record<string, unknown> | undefined;
return r ? toCustomer(r) : undefined;
}
list(): Customer[] {
const rows = this.db.prepare('SELECT * FROM customers ORDER BY id').all() as Record<
string,
unknown
>[];
return rows.map(toCustomer);
}
save(customer: Customer): void {
this.db
.prepare(
`INSERT INTO customers (id, name, tier_id, api_key, stripe_customer_id, multiplier_override, billing_type, email, password_hash, totp_secret, totp_enabled, balance_cents, email_invoicing)
VALUES (@id, @name, @tierId, @apiKey, @stripeCustomerId, @multiplierOverride, @billingType, @email, @passwordHash, @totpSecret, @totpEnabled, @balanceCents, @emailInvoicing)
ON CONFLICT(id) DO UPDATE SET
name = @name,
tier_id = @tierId,
api_key = @apiKey,
stripe_customer_id = @stripeCustomerId,
multiplier_override = @multiplierOverride,
billing_type = @billingType,
email = @email,
password_hash = @passwordHash,
totp_secret = @totpSecret,
totp_enabled = @totpEnabled,
balance_cents = @balanceCents,
email_invoicing = @emailInvoicing`,
)
.run({
id: customer.id,
name: customer.name,
tierId: customer.tierId,
apiKey: customer.apiKey,
stripeCustomerId: customer.stripeCustomerId ?? null,
multiplierOverride: customer.multiplierOverride ?? null,
billingType: customer.billingType ?? 'stripe',
email: customer.email ?? null,
passwordHash: customer.passwordHash ?? null,
totpSecret: customer.totpSecret ?? null,
totpEnabled: customer.totpEnabled ? 1 : 0,
balanceCents: customer.balanceCents ?? 0,
emailInvoicing: customer.emailInvoicing ? 1 : 0,
});
}
}
function toCustomer(r: Record<string, unknown>): Customer {
return {
id: r.id as string,
name: r.name as string,
tierId: r.tier_id as string,
apiKey: r.api_key as string,
stripeCustomerId: (r.stripe_customer_id as string | null) ?? undefined,
multiplierOverride: (r.multiplier_override as number | null) ?? undefined,
billingType: ((r.billing_type as string | null) ?? 'stripe') as Customer['billingType'],
email: (r.email as string | null) ?? undefined,
passwordHash: (r.password_hash as string | null) ?? undefined,
totpSecret: (r.totp_secret as string | null) ?? undefined,
totpEnabled: ((r.totp_enabled as number | null) ?? 0) === 1,
balanceCents: (r.balance_cents as number | null) ?? 0,
emailInvoicing: ((r.email_invoicing as number | null) ?? 0) === 1,
};
}

119
src/db/invoice-repo.ts Normal file
View file

@ -0,0 +1,119 @@
import Database from 'better-sqlite3';
import { Invoice, InvoiceLine, InvoiceRepo } from '../invoicing';
export class SqliteInvoiceRepo implements InvoiceRepo {
constructor(private db: Database.Database) {
this.db.exec(`
CREATE TABLE IF NOT EXISTS invoices (
id TEXT PRIMARY KEY,
customer_id TEXT NOT NULL,
period TEXT NOT NULL,
status TEXT NOT NULL,
total_cents INTEGER NOT NULL,
credit_cents INTEGER NOT NULL,
billable_cents INTEGER NOT NULL,
billing_type TEXT NOT NULL DEFAULT 'stripe',
po_number TEXT,
issued_at_ms INTEGER,
due_at_ms INTEGER,
paid_at_ms INTEGER
);
CREATE TABLE IF NOT EXISTS invoice_lines (
invoice_id TEXT NOT NULL,
endpoint_id TEXT NOT NULL,
calls INTEGER NOT NULL,
cents INTEGER NOT NULL,
PRIMARY KEY (invoice_id, endpoint_id)
);
`);
}
save(invoice: Invoice): void {
this.db
.prepare(
`INSERT INTO invoices
(id, customer_id, period, status, total_cents, credit_cents, billable_cents,
billing_type, po_number, issued_at_ms, due_at_ms, paid_at_ms)
VALUES (@id, @customerId, @period, @status, @totalCents, @creditCents, @billableCents,
@billingType, @poNumber, @issuedAtMs, @dueAtMs, @paidAtMs)
ON CONFLICT(id) DO UPDATE SET
customer_id = @customerId, period = @period, status = @status,
total_cents = @totalCents, credit_cents = @creditCents,
billable_cents = @billableCents, billing_type = @billingType,
po_number = @poNumber, issued_at_ms = @issuedAtMs,
due_at_ms = @dueAtMs, paid_at_ms = @paidAtMs`,
)
.run({
...invoice,
poNumber: invoice.poNumber ?? null,
issuedAtMs: invoice.issuedAtMs ?? null,
dueAtMs: invoice.dueAtMs ?? null,
paidAtMs: invoice.paidAtMs ?? null,
});
// Replace the line set atomically with the invoice row.
this.db.prepare('DELETE FROM invoice_lines WHERE invoice_id = ?').run(invoice.id);
const insert = this.db.prepare(
'INSERT INTO invoice_lines (invoice_id, endpoint_id, calls, cents) VALUES (?, ?, ?, ?)',
);
for (const line of invoice.lines) {
insert.run(invoice.id, line.endpointId, line.calls, line.cents);
}
}
get(id: string): Invoice | undefined {
const row = this.db.prepare('SELECT * FROM invoices WHERE id = ?').get(id) as
| Record<string, unknown>
| undefined;
return row ? this.toInvoice(row) : undefined;
}
list(filter: { customerId?: string; period?: string; status?: Invoice['status'] }): Invoice[] {
const where: string[] = [];
const params: Record<string, string> = {};
if (filter.customerId) {
where.push('customer_id = @customerId');
params.customerId = filter.customerId;
}
if (filter.period) {
where.push('period = @period');
params.period = filter.period;
}
if (filter.status) {
where.push('status = @status');
params.status = filter.status;
}
const sql = `SELECT * FROM invoices ${where.length ? `WHERE ${where.join(' AND ')}` : ''} ORDER BY id`;
const rows = this.db.prepare(sql).all(params) as Record<string, unknown>[];
return rows.map((r) => this.toInvoice(r));
}
nextSequence(period: string): number {
const { n } = this.db
.prepare('SELECT COUNT(*) AS n FROM invoices WHERE period = ?')
.get(period) as { n: number };
return n + 1;
}
private toInvoice(row: Record<string, unknown>): Invoice {
const lines = this.db
.prepare('SELECT endpoint_id, calls, cents FROM invoice_lines WHERE invoice_id = ? ORDER BY endpoint_id')
.all(row.id as string) as { endpoint_id: string; calls: number; cents: number }[];
return {
id: row.id as string,
customerId: row.customer_id as string,
period: row.period as string,
status: row.status as Invoice['status'],
lines: lines.map(
(l): InvoiceLine => ({ endpointId: l.endpoint_id, calls: l.calls, cents: l.cents }),
),
totalCents: row.total_cents as number,
creditCents: row.credit_cents as number,
billableCents: row.billable_cents as number,
billingType: row.billing_type as Invoice['billingType'],
poNumber: (row.po_number as string | null) ?? undefined,
issuedAtMs: (row.issued_at_ms as number | null) ?? undefined,
dueAtMs: (row.due_at_ms as number | null) ?? undefined,
paidAtMs: (row.paid_at_ms as number | null) ?? undefined,
};
}
}

106
src/db/pricing-store.ts Normal file
View file

@ -0,0 +1,106 @@
import Database from 'better-sqlite3';
import {
DEFAULT_RATE_CARD,
DEFAULT_TIERS,
PriceRule,
PricingStore,
RateCard,
TierConfig,
} from '../pricing';
export class SqlitePricingStore implements PricingStore {
constructor(
private db: Database.Database,
seedCard: RateCard = DEFAULT_RATE_CARD,
seedTiers: TierConfig[] = DEFAULT_TIERS,
) {
this.db.exec(`
CREATE TABLE IF NOT EXISTS price_endpoints (
endpoint_id TEXT PRIMARY KEY,
rule_json TEXT NOT NULL
);
CREATE TABLE IF NOT EXISTS tiers (
id TEXT PRIMARY KEY,
name TEXT NOT NULL,
multiplier REAL NOT NULL,
monthly_credit_cents INTEGER NOT NULL,
default_rule_json TEXT
)
`);
const { n: endpointCount } = this.db
.prepare('SELECT COUNT(*) AS n FROM price_endpoints')
.get() as { n: number };
if (endpointCount === 0) {
for (const [id, rule] of Object.entries(seedCard.endpoints)) {
this.upsertEndpoint(id, rule);
}
}
const { n: tierCount } = this.db.prepare('SELECT COUNT(*) AS n FROM tiers').get() as {
n: number;
};
if (tierCount === 0) {
for (const tier of seedTiers) {
this.upsertTier(tier);
}
}
}
getRateCard(): RateCard {
const rows = this.db.prepare('SELECT * FROM price_endpoints').all() as Record<string, unknown>[];
const endpoints: Record<string, PriceRule> = {};
for (const r of rows) {
endpoints[r.endpoint_id as string] = JSON.parse(r.rule_json as string) as PriceRule;
}
return { endpoints };
}
getTiers(): TierConfig[] {
const rows = this.db.prepare('SELECT * FROM tiers ORDER BY rowid').all() as Record<string, unknown>[];
return rows.map((r) => ({
id: r.id as string,
name: r.name as string,
multiplier: r.multiplier as number,
monthlyCreditCents: r.monthly_credit_cents as number,
defaultRule: r.default_rule_json
? (JSON.parse(r.default_rule_json as string) as PriceRule)
: undefined,
}));
}
upsertEndpoint(endpointId: string, rule: PriceRule): void {
this.db
.prepare(
`INSERT INTO price_endpoints (endpoint_id, rule_json) VALUES (?, ?)
ON CONFLICT(endpoint_id) DO UPDATE SET rule_json = excluded.rule_json`,
)
.run(endpointId, JSON.stringify(rule));
}
deleteEndpoint(endpointId: string): void {
this.db.prepare('DELETE FROM price_endpoints WHERE endpoint_id = ?').run(endpointId);
}
upsertTier(tier: TierConfig): void {
this.db
.prepare(
`INSERT INTO tiers (id, name, multiplier, monthly_credit_cents, default_rule_json)
VALUES (@id, @name, @multiplier, @monthlyCreditCents, @defaultRuleJson)
ON CONFLICT(id) DO UPDATE SET
name = @name,
multiplier = @multiplier,
monthly_credit_cents = @monthlyCreditCents,
default_rule_json = @defaultRuleJson`,
)
.run({
id: tier.id,
name: tier.name,
multiplier: tier.multiplier,
monthlyCreditCents: tier.monthlyCreditCents,
defaultRuleJson: tier.defaultRule ? JSON.stringify(tier.defaultRule) : null,
});
}
deleteTier(tierId: string): void {
this.db.prepare('DELETE FROM tiers WHERE id = ?').run(tierId);
}
}

51
src/db/session-repo.ts Normal file
View file

@ -0,0 +1,51 @@
import Database from 'better-sqlite3';
import { newSessionToken, PortalSession, SessionRepo } from '../accounts';
/** SQLite-backed portal sessions; survives server restarts. */
export class SqliteSessionRepo implements SessionRepo {
constructor(private db: Database.Database) {
this.db.exec(`
CREATE TABLE IF NOT EXISTS portal_sessions (
token TEXT PRIMARY KEY,
customer_id TEXT NOT NULL,
created_ms INTEGER NOT NULL,
expires_ms INTEGER NOT NULL
)
`);
}
create(customerId: string, ttlMs: number): PortalSession {
const now = Date.now();
const session: PortalSession = {
token: newSessionToken(),
customerId,
createdMs: now,
expiresMs: now + ttlMs,
};
this.db
.prepare(
'INSERT INTO portal_sessions (token, customer_id, created_ms, expires_ms) VALUES (?, ?, ?, ?)',
)
.run(session.token, session.customerId, session.createdMs, session.expiresMs);
return session;
}
get(token: string, nowMs = Date.now()): PortalSession | undefined {
const r = this.db
.prepare('SELECT * FROM portal_sessions WHERE token = ?')
.get(token) as
| { token: string; customer_id: string; created_ms: number; expires_ms: number }
| undefined;
if (!r || r.expires_ms <= nowMs) return undefined;
return {
token: r.token,
customerId: r.customer_id,
createdMs: r.created_ms,
expiresMs: r.expires_ms,
};
}
delete(token: string): void {
this.db.prepare('DELETE FROM portal_sessions WHERE token = ?').run(token);
}
}

61
src/db/usage-repo.ts Normal file
View file

@ -0,0 +1,61 @@
import Database from 'better-sqlite3';
import { summarize, UsageEntry, UsageRepo, UsageSummary } from '../usage';
export class SqliteUsageRepo implements UsageRepo {
constructor(private db: Database.Database) {
this.db.exec(`
CREATE TABLE IF NOT EXISTS usage_entries (
id INTEGER PRIMARY KEY AUTOINCREMENT,
customer_id TEXT NOT NULL,
endpoint_id TEXT NOT NULL,
cents INTEGER NOT NULL,
metadata_bytes INTEGER NOT NULL,
attachment_bytes INTEGER NOT NULL,
timestamp_ms INTEGER NOT NULL
)
`);
}
record(entry: UsageEntry): void {
this.db
.prepare(
`INSERT INTO usage_entries
(customer_id, endpoint_id, cents, metadata_bytes, attachment_bytes, timestamp_ms)
VALUES (?, ?, ?, ?, ?, ?)`,
)
.run(
entry.customerId,
entry.endpointId,
entry.cents,
entry.metadataBytes,
entry.attachmentBytes,
entry.timestamp.getTime(),
);
}
listFor(customerId: string, since?: Date): UsageEntry[] {
const rows = (
since
? this.db
.prepare(
'SELECT * FROM usage_entries WHERE customer_id = ? AND timestamp_ms >= ? ORDER BY timestamp_ms',
)
.all(customerId, since.getTime())
: this.db
.prepare('SELECT * FROM usage_entries WHERE customer_id = ? ORDER BY timestamp_ms')
.all(customerId)
) as Record<string, unknown>[];
return rows.map((r) => ({
customerId: r.customer_id as string,
endpointId: r.endpoint_id as string,
cents: r.cents as number,
metadataBytes: r.metadata_bytes as number,
attachmentBytes: r.attachment_bytes as number,
timestamp: new Date(r.timestamp_ms as number),
}));
}
summaryFor(customerId: string, since?: Date): UsageSummary {
return summarize(customerId, this.listFor(customerId, since));
}
}

42
src/index.ts Normal file
View file

@ -0,0 +1,42 @@
import path from 'path';
import Database from 'better-sqlite3';
import { config as loadEnv } from 'dotenv';
import { buildApp, DEFAULT_CUSTOMERS } from './app';
import { seedAdminUsersFromEnv } from './admin-users';
import { hasRealStripeKey, stripePaymentClient } from './billing/reload';
import { SqliteAdminUserRepo } from './db/admin-user-repo';
import { SqliteCustomerRepo } from './db/customer-repo';
import { SqliteInvoiceRepo } from './db/invoice-repo';
import { SqlitePricingStore } from './db/pricing-store';
import { SqliteSessionRepo } from './db/session-repo';
import { SqliteUsageRepo } from './db/usage-repo';
import { PROJECT_ROOT } from './paths';
import { PaymentClient } from './portal';
loadEnv({ path: path.join(PROJECT_ROOT, '.env') });
const db = new Database(process.env.ZAPPIER_DB ?? path.join(PROJECT_ROOT, 'zappier.db'));
const usage = new SqliteUsageRepo(db);
const customers = new SqliteCustomerRepo(db, DEFAULT_CUSTOMERS);
const pricingStore = new SqlitePricingStore(db);
const invoices = new SqliteInvoiceRepo(db);
const adminUsers = SqliteAdminUserRepo.seeded(db, seedAdminUsersFromEnv());
const sessions = new SqliteSessionRepo(db);
// Real Stripe reloads when a usable key is configured; otherwise a dev client
// credits the balance immediately (placeholder key, local development).
const payments: PaymentClient = hasRealStripeKey(process.env.STRIPE_SECRET_KEY)
? stripePaymentClient(process.env.STRIPE_SECRET_KEY)
: {
reload: async (_customer, amountCents) => ({ mode: 'dev', creditedCents: amountCents }),
};
const { app } = buildApp({ usage, customers, pricingStore, invoices, adminUsers, sessions, payments });
const port = Number(process.env.PORT ?? 3000);
const bind = process.env.BIND ?? '127.0.0.1';
app.listen(port, bind, () => {
console.log(`Zappier API listening on http://${bind}:${port}`);
console.log(`OpenAPI docs at http://localhost:${port}/docs`);
console.log(`Customer portal at http://localhost:${port}/portal`);
console.log(`Admin console at http://localhost:${port}/admin`);
});

106
src/invoicing.ts Normal file
View file

@ -0,0 +1,106 @@
import { Customer } from './auth';
import { TierConfig } from './pricing';
import { UsageEntry } from './usage';
export interface InvoiceLine {
endpointId: string;
calls: number;
cents: number;
}
export interface Invoice {
/** INV-<period>-<zero-padded sequence>, e.g. INV-2026-07-0007 */
id: string;
customerId: string;
/** Billing period, YYYY-MM */
period: string;
status: 'draft' | 'issued' | 'paid';
lines: InvoiceLine[];
/** Gross usage in cents (sum of lines, before credit). */
totalCents: number;
/** Monthly credit actually consumed. */
creditCents: number;
/** totalCents creditCents, floored at 0. */
billableCents: number;
billingType: 'stripe' | 'purchase_order';
poNumber?: string;
issuedAtMs?: number;
dueAtMs?: number;
paidAtMs?: number;
}
export interface InvoiceRepo {
save(invoice: Invoice): void;
get(id: string): Invoice | undefined;
list(filter: {
customerId?: string;
period?: string;
status?: Invoice['status'];
}): Invoice[];
/** 1-based sequence for the next invoice id within a period. */
nextSequence(period: string): number;
}
/** Test/dev adapter; production uses SqliteInvoiceRepo. */
export class InMemoryInvoiceRepo implements InvoiceRepo {
private invoices = new Map<string, Invoice>();
save(invoice: Invoice): void {
this.invoices.set(invoice.id, { ...invoice, lines: invoice.lines.map((l) => ({ ...l })) });
}
get(id: string): Invoice | undefined {
return this.invoices.get(id);
}
list(filter: {
customerId?: string;
period?: string;
status?: Invoice['status'];
}): Invoice[] {
return [...this.invoices.values()]
.filter((i) => (filter.customerId ? i.customerId === filter.customerId : true))
.filter((i) => (filter.period ? i.period === filter.period : true))
.filter((i) => (filter.status ? i.status === filter.status : true))
.sort((a, b) => a.id.localeCompare(b.id));
}
nextSequence(period: string): number {
return [...this.invoices.values()].filter((i) => i.period === period).length + 1;
}
}
export function buildInvoice(args: {
customer: Customer;
period: string;
sequence: number;
entries: UsageEntry[];
tier: TierConfig;
poNumber?: string;
}): Invoice {
const { customer, period, sequence, entries, tier, poNumber } = args;
const byEndpoint = new Map<string, InvoiceLine>();
for (const e of entries) {
const line = byEndpoint.get(e.endpointId) ?? { endpointId: e.endpointId, calls: 0, cents: 0 };
line.calls += 1;
line.cents += e.cents;
byEndpoint.set(e.endpointId, line);
}
const lines = [...byEndpoint.values()].sort((a, b) =>
a.endpointId.localeCompare(b.endpointId),
);
const totalCents = lines.reduce((sum, l) => sum + l.cents, 0);
const creditCents = Math.min(totalCents, tier.monthlyCreditCents);
return {
id: `INV-${period}-${String(sequence).padStart(4, '0')}`,
customerId: customer.id,
period,
status: 'draft',
lines,
totalCents,
creditCents,
billableCents: totalCents - creditCents,
billingType: customer.billingType ?? 'stripe',
...(poNumber !== undefined ? { poNumber } : {}),
};
}

129
src/jobs/report-usage.ts Normal file
View file

@ -0,0 +1,129 @@
import Database from 'better-sqlite3';
import path from 'path';
import Stripe from 'stripe';
import { DEFAULT_CUSTOMERS } from '../app';
import { CustomerRepo } from '../auth';
import {
computeBillableCents,
computeDelta,
MeterEventClient,
METER_EVENT_NAME,
} from '../billing/stripe';
import { BillingReportRepo, JobLockRepo, SqliteBillingReportRepo } from '../db/billing-repo';
import { SqliteCustomerRepo } from '../db/customer-repo';
import { SqlitePricingStore } from '../db/pricing-store';
import { SqliteUsageRepo } from '../db/usage-repo';
import { PROJECT_ROOT } from '../paths';
import { TierConfig } from '../pricing';
import { UsageRepo } from '../usage';
export interface ReportUsageDeps {
client: MeterEventClient;
usage: Pick<UsageRepo, 'listFor'>;
customers: Pick<CustomerRepo, 'list'>;
tiers: TierConfig[];
billingRepo: BillingReportRepo;
locks: JobLockRepo;
since?: Date;
log?: (message: string) => void;
warn?: (message: string) => void;
}
export const REPORT_USAGE_LOCK = 'report-usage';
// A crashed run leaves the lock behind; take it over after one hour.
const LOCK_TTL_MS = 60 * 60 * 1000;
export function firstOfMonthUtc(now: Date): Date {
const since = new Date(now);
since.setUTCDate(1);
since.setUTCHours(0, 0, 0, 0);
return since;
}
export async function reportMonthlyUsage(deps: ReportUsageDeps): Promise<void> {
const log = deps.log ?? console.log;
const warn = deps.warn ?? console.warn;
if (!deps.locks.tryAcquireLock(REPORT_USAGE_LOCK, LOCK_TTL_MS)) {
log(`${REPORT_USAGE_LOCK}: another run holds the lock, abort run`);
return;
}
try {
const since = deps.since ?? firstOfMonthUtc(new Date());
const period = since.toISOString().slice(0, 7);
for (const customer of deps.customers.list()) {
if (!customer.stripeCustomerId) continue;
const tier = deps.tiers.find((t) => t.id === customer.tierId);
if (!tier) {
warn(`${customer.id}: unknown tier ${customer.tierId}, skipped`);
continue;
}
const entries = deps.usage.listFor(customer.id, since);
const billable = computeBillableCents(entries, tier.monthlyCreditCents);
const prior = deps.billingRepo.getReportedCents(customer.id, period);
const delta = computeDelta(billable, prior);
if (delta <= 0) {
log(
prior > 0
? `skip ${customer.id} ${period} (already reported ${prior}c)`
: `skip ${customer.id} ${period} (nothing to report)`,
);
continue;
}
// Stripe meter events are additive: report only the delta, then record the
// cumulative billable amount. The ledger is updated only after the meter
// event succeeds so a failed run retries with the full outstanding delta.
// The identifier lets Stripe drop a duplicate if we crash after the meter
// event succeeds but before the ledger upsert commits and then retry.
await deps.client.createMeterEvent({
eventName: METER_EVENT_NAME,
customerId: customer.stripeCustomerId,
value: String(delta),
identifier: `${customer.stripeCustomerId}:${period}:${billable}`,
});
deps.billingRepo.upsertReportedCents(customer.id, period, billable);
log(`${customer.id}: reported ${delta} billable cents to Stripe`);
}
} finally {
deps.locks.releaseLock(REPORT_USAGE_LOCK);
}
}
async function main(): Promise<void> {
// Loads STRIPE_SECRET_KEY (and ZAPPIER_DB, if set) from the gitignored .env
// at the project root, regardless of the process working directory.
const { config: loadEnv } = await import('dotenv');
loadEnv({ path: path.join(PROJECT_ROOT, '.env') });
const stripe = new Stripe(process.env.STRIPE_SECRET_KEY!);
const client: MeterEventClient = {
createMeterEvent: async (p) => {
await stripe.billing.meterEvents.create({
event_name: METER_EVENT_NAME,
payload: { stripe_customer_id: p.customerId, value: p.value },
...(p.identifier ? { identifier: p.identifier } : {}),
});
},
};
const db = new Database(process.env.ZAPPIER_DB ?? path.join(PROJECT_ROOT, 'zappier.db'));
const usage = new SqliteUsageRepo(db);
const customers = new SqliteCustomerRepo(db, DEFAULT_CUSTOMERS);
const pricingStore = new SqlitePricingStore(db);
const billingRepo = new SqliteBillingReportRepo(db);
await reportMonthlyUsage({
client,
usage,
customers,
tiers: pricingStore.getTiers(),
billingRepo,
locks: billingRepo,
});
}
if (require.main === module) {
main().catch((err) => {
console.error(err);
process.exit(1);
});
}

65
src/ledger.ts Normal file
View file

@ -0,0 +1,65 @@
/**
* Prepaid mutations. Account-balance is the writer when NATS or HTTP books exist.
* Tests (no NATS_URL, no ACCOUNT_BALANCE_URL) keep a local cache only.
*/
import { natsAdjust, natsPublish, natsStatement } from './billing-nats';
import type { AccessPlane } from './billing-nats';
export type PrepaidRow = {
customerId: string;
veraeUserId?: string;
cents: number;
reason: string;
agent: string;
kind?: string;
prepaidCents?: number;
};
function booksUrl(): string {
return (process.env.ACCOUNT_BALANCE_URL || '').replace(/\/$/, '');
}
export function booksConfigured(): boolean {
return Boolean(process.env.NATS_URL || booksUrl());
}
export async function ledgerAdjust(row: PrepaidRow, plane: AccessPlane): Promise<PrepaidRow | null> {
const viaNats = await natsAdjust(row, plane);
if (viaNats && typeof viaNats === 'object' && viaNats !== null && 'prepaidCents' in viaNats) {
return viaNats as PrepaidRow;
}
const base = booksUrl();
if (!base) return null;
const r = await fetch(`${base}/adjust`, {
method: 'POST',
headers: { 'content-type': 'application/json' },
body: JSON.stringify(row),
});
if (!r.ok) return null;
return (await r.json()) as PrepaidRow;
}
export async function ledgerPutCustomer(row: {
customerId: string;
name?: string;
veraeUserId?: string;
}): Promise<void> {
natsPublish('verae.billing.customer.put', row, 'staff');
const base = booksUrl();
if (!base) return;
await fetch(`${base}/customer`, {
method: 'POST',
headers: { 'content-type': 'application/json' },
body: JSON.stringify(row),
}).catch(() => {});
}
export async function ledgerStatement(customerId: string, plane: AccessPlane, veraeUserId?: string) {
const nats = await natsStatement(customerId, plane, veraeUserId);
if (nats) return nats;
const base = booksUrl();
if (!base) return null;
const r = await fetch(`${base}/statement/${encodeURIComponent(customerId)}`);
if (!r.ok) return null;
return r.json();
}

51
src/meter.ts Normal file
View file

@ -0,0 +1,51 @@
import { RequestHandler } from 'express';
import { PricingContext, Quote, quoteCall } from './pricing';
import { UsageRepo } from './usage';
export function meter(
endpointId: string,
repo: UsageRepo,
pricing: PricingContext,
onRecord?: (entry: { customerId: string; endpointId: string; cents: number }) => void,
): RequestHandler {
return (req, res, next) => {
const customer = req.customer;
if (!customer) {
res.status(401).json({ error: 'unauthenticated' });
return;
}
const metadataBytes = Buffer.byteLength(
JSON.stringify(req.body?.metadata ?? {}),
'utf8',
);
const files = (req.files as Express.Multer.File[] | undefined) ?? [];
const attachmentBytes = files.reduce((sum, f) => sum + f.size, 0);
let quote: Quote;
try {
quote = quoteCall(
pricing,
customer.tierId,
endpointId,
{ metadataBytes, attachmentBytes },
customer.multiplierOverride,
);
} catch (err) {
res.status(403).json({ error: (err as Error).message });
return;
}
const entry = {
customerId: customer.id,
endpointId,
cents: quote.totalCents,
metadataBytes,
attachmentBytes,
timestamp: new Date(),
};
repo.record(entry);
onRecord?.({ customerId: customer.id, endpointId, cents: quote.totalCents });
res.locals.quote = quote;
next();
};
}

11
src/nats-shim.d.ts vendored Normal file
View file

@ -0,0 +1,11 @@
declare module 'nats' {
export function connect(opts: unknown): Promise<{
request(s: string, d: Uint8Array, o: { timeout: number }): Promise<{ data: Uint8Array }>;
publish(s: string, d: Uint8Array): void;
close(): Promise<void>;
}>;
export function StringCodec(): {
encode(s: string): Uint8Array;
decode(u: Uint8Array): string;
};
}

9
src/paths.ts Normal file
View file

@ -0,0 +1,9 @@
import path from 'path';
/**
* Absolute path of the project root, resolved from this module's location so
* the app works from any working directory (systemd, Docker, cron, `node
* dist/index.js` from elsewhere). Holds for both layouts: ts-node running
* src/ (root is one level up) and compiled dist/ (same one level up).
*/
export const PROJECT_ROOT = path.join(__dirname, '..');

341
src/portal.ts Normal file
View file

@ -0,0 +1,341 @@
import { randomBytes } from 'crypto';
import { RequestHandler, Router } from 'express';
import {
generateTotpSecret,
hashPassword,
SessionRepo,
totpUri,
verifyPassword,
verifyTotp,
} from './accounts';
import { renderInvoiceHtml } from './admin';
import { Customer, CustomerRepo } from './auth';
import { applyMonthlyCredit } from './billing/credit';
import { InvoiceRepo } from './invoicing';
import { RateCard, TierConfig } from './pricing';
import { UsageRepo } from './usage';
import { CreditLedger } from './credits';
import { composeStatement } from './statement';
import { BILLING_SUBJECTS, natsPublish } from './billing-nats';
import { bindVeraeUser } from './verae-bind';
import { booksConfigured, ledgerAdjust, ledgerStatement } from './ledger';
/**
* Customer portal API (/portal/api): signup, login with optional TOTP 2FA,
* profile, API-key regeneration, usage, own invoices, prepaid reloads, and
* email-invoicing preferences. Sessions authenticate via Bearer token.
*/
export interface PaymentResult {
mode: 'stripe' | 'dev';
/** Cents actually credited to the balance now (0 while a Stripe intent awaits confirmation). */
creditedCents: number;
clientSecret?: string;
}
export interface PaymentClient {
reload(customer: Customer, amountCents: number): Promise<PaymentResult>;
}
export interface PortalDeps {
customers: CustomerRepo;
sessions: SessionRepo;
usage: UsageRepo;
invoices: InvoiceRepo;
tiers: () => TierConfig[];
rateCard: () => RateCard;
payments: PaymentClient;
/** Renders an otpauth URI as a QR data URL; injectable for tests. */
qr: (uri: string) => Promise<string>;
sessionTtlMs?: number;
now?: () => number;
credits?: CreditLedger;
}
const DEFAULT_SESSION_TTL_MS = 7 * 24 * 60 * 60 * 1000;
const MIN_RELOAD_CENTS = 100; // $1
const MAX_RELOAD_CENTS = 1_000_000; // $10,000
/** Profile payload safe to return to the customer (no hashes/secrets). */
function publicProfile(c: Customer) {
return {
id: c.id,
name: c.name,
email: c.email,
tierId: c.tierId,
billingType: c.billingType ?? 'stripe',
apiKey: c.apiKey,
veraeUserId: c.veraeUserId,
balanceCents: c.balanceCents ?? 0,
totpEnabled: c.totpEnabled ?? false,
emailInvoicing: c.emailInvoicing ?? false,
};
}
function sessionAuth(deps: PortalDeps): RequestHandler {
return (req, res, next) => {
const bearer = req.header('authorization');
const token = bearer?.startsWith('Bearer ') ? bearer.slice(7) : undefined;
const session = token ? deps.sessions.get(token, (deps.now ?? Date.now)()) : undefined;
const customer = session
? deps.customers.list().find((c) => c.id === session.customerId)
: undefined;
if (!customer) {
res.status(401).json({ error: 'invalid or expired session' });
return;
}
req.customer = customer;
next();
};
}
function save(deps: PortalDeps, customer: Customer): void {
deps.customers.save(customer);
}
export function portalRouter(deps: PortalDeps): Router {
const router = Router();
const ttl = deps.sessionTtlMs ?? DEFAULT_SESSION_TTL_MS;
const now = deps.now ?? Date.now;
/* ---------------- auth ---------------- */
router.post('/signup', async (req, res) => {
const { name, email, password } = req.body ?? {};
if (typeof name !== 'string' || name.trim().length === 0) {
res.status(400).json({ error: 'name is required' });
return;
}
if (typeof email !== 'string' || !/^[^@\s]+@[^@\s]+\.[^@\s]+$/.test(email)) {
res.status(400).json({ error: 'valid email is required' });
return;
}
if (typeof password !== 'string' || password.length < 8) {
res.status(400).json({ error: 'password must be at least 8 characters' });
return;
}
const passwordHash = hashPassword(password);
const existing = deps.customers.findByEmail(email);
let customer: Customer;
if (existing) {
if (existing.passwordHash) {
res.status(409).json({ error: 'an account with this email already exists' });
return;
}
// Claim flow: an admin-created customer sets their portal password once.
customer = { ...existing, name: existing.name || name.trim(), email, passwordHash };
} else {
customer = {
id: `cust_${randomBytes(4).toString('hex')}`,
name: name.trim(),
email,
tierId: 'free',
apiKey: `key-${randomBytes(8).toString('hex')}`,
billingType: 'stripe',
passwordHash,
balanceCents: 0,
};
}
if (!customer.veraeUserId) {
const bind = await bindVeraeUser(email);
customer = { ...customer, veraeUserId: bind.veraeUserId, veraeUsername: bind.veraeUsername };
}
save(deps, customer);
const session = deps.sessions.create(customer.id, ttl);
res.status(201).json({ token: session.token, customer: publicProfile(customer) });
});
router.post('/login', (req, res) => {
const { email, password, totpCode } = req.body ?? {};
if (typeof email !== 'string' || typeof password !== 'string') {
res.status(400).json({ error: 'email and password are required' });
return;
}
const customer = deps.customers.findByEmail(email);
if (!customer?.passwordHash || !verifyPassword(password, customer.passwordHash)) {
res.status(401).json({ error: 'invalid email or password' });
return;
}
if (customer.totpEnabled) {
if (
typeof totpCode !== 'string' ||
!customer.totpSecret ||
!verifyTotp(customer.totpSecret, totpCode, now())
) {
res.status(401).json({ error: 'totp_required' });
return;
}
}
const session = deps.sessions.create(customer.id, ttl);
res.json({ token: session.token, customer: publicProfile(customer) });
});
router.use(sessionAuth(deps));
router.post('/logout', (req, res) => {
const bearer = req.header('authorization')!;
deps.sessions.delete(bearer.slice(7));
res.json({ ok: true });
});
/* ---------------- profile + API key ---------------- */
router.get('/me', (req, res) => {
res.json(publicProfile(req.customer!));
});
router.post('/api-key', (req, res) => {
const customer = { ...req.customer!, apiKey: `key-${randomBytes(8).toString('hex')}` };
save(deps, customer);
res.json({ apiKey: customer.apiKey });
});
/* ---------------- usage + invoices ---------------- */
router.get('/usage', (req, res) => {
const since = new Date(now());
since.setUTCDate(1);
since.setUTCHours(0, 0, 0, 0);
const summary = deps.usage.summaryFor(req.customer!.id, since);
const tier = deps.tiers().find((t) => t.id === req.customer!.tierId);
res.json(tier ? applyMonthlyCredit(summary, tier) : summary);
});
router.get('/invoices', (req, res) => {
res.json({ invoices: deps.invoices.list({ customerId: req.customer!.id }) });
});
router.get('/pricing', (req, res) => {
res.json({ tiers: deps.tiers(), rateCard: deps.rateCard() });
});
router.get('/statement', async (req, res) => {
const fromBooks = await ledgerStatement(req.customer!.id, 'web', req.customer!.veraeUserId);
if (fromBooks) {
res.json({
...fromBooks,
name: req.customer!.name,
source: fromBooks.source || 'account-balance',
});
return;
}
res.json({
...composeStatement({
customerId: req.customer!.id,
name: req.customer!.name,
veraeUserId: req.customer!.veraeUserId,
prepaidCents: req.customer!.balanceCents ?? 0,
credits: (deps.credits || new CreditLedger()).list(req.customer!.id),
usage: deps.usage.listFor(req.customer!.id),
invoices: deps.invoices.list({ customerId: req.customer!.id }),
}),
source: 'local',
});
});
router.get('/invoices/:id', (req, res) => {
const invoice = deps.invoices.get(req.params.id);
if (!invoice || invoice.customerId !== req.customer!.id) {
res.status(404).json({ error: 'invoice not found' });
return;
}
if (req.query.format === 'html') {
res.type('html').send(renderInvoiceHtml(invoice, req.customer!.name));
return;
}
res.json(invoice);
});
/* ---------------- 2FA ---------------- */
router.post('/2fa/setup', async (req, res) => {
const secret = generateTotpSecret();
save(deps, { ...req.customer!, totpSecret: secret, totpEnabled: false });
const uri = totpUri(secret, req.customer!.email ?? req.customer!.id);
res.json({ secret, uri, qr: await deps.qr(uri) });
});
router.post('/2fa/enable', (req, res) => {
const { code } = req.body ?? {};
const secret = req.customer!.totpSecret;
if (!secret || typeof code !== 'string' || !verifyTotp(secret, code, now())) {
res.status(400).json({ error: 'invalid code — scan the QR and try the current 6-digit code' });
return;
}
save(deps, { ...req.customer!, totpEnabled: true });
res.json({ ok: true, totpEnabled: true });
});
router.post('/2fa/disable', (req, res) => {
const { code } = req.body ?? {};
const secret = req.customer!.totpSecret;
if (!secret || typeof code !== 'string' || !verifyTotp(secret, code, now())) {
res.status(400).json({ error: 'invalid code' });
return;
}
save(deps, { ...req.customer!, totpSecret: undefined, totpEnabled: false });
res.json({ ok: true, totpEnabled: false });
});
/* ---------------- billing ---------------- */
router.post('/reload', async (req, res) => {
const { amountCents } = req.body ?? {};
if (
typeof amountCents !== 'number' ||
!Number.isInteger(amountCents) ||
amountCents < MIN_RELOAD_CENTS ||
amountCents > MAX_RELOAD_CENTS
) {
res
.status(400)
.json({ error: `amountCents must be an integer between ${MIN_RELOAD_CENTS} and ${MAX_RELOAD_CENTS}` });
return;
}
const result = await deps.payments.reload(req.customer!, amountCents);
let prepaid = req.customer!.balanceCents ?? 0;
if (result.creditedCents > 0) {
const row = await ledgerAdjust(
{
customerId: req.customer!.id,
veraeUserId: req.customer!.veraeUserId,
cents: result.creditedCents,
reason: 'reload',
agent: 'portal',
kind: 'reload',
},
'web',
);
if (row && typeof row.prepaidCents === 'number') prepaid = row.prepaidCents;
else if (!booksConfigured()) prepaid += result.creditedCents;
save(deps, { ...req.customer!, balanceCents: prepaid });
natsPublish(
BILLING_SUBJECTS.PAYMENT_RECORDED,
{
customerId: req.customer!.id,
veraeUserId: req.customer!.veraeUserId,
cents: result.creditedCents,
reason: 'reload',
},
'web',
);
}
res.json({
balanceCents: prepaid,
mode: result.mode,
...(result.clientSecret ? { clientSecret: result.clientSecret } : {}),
});
});
router.put('/email-invoicing', (req, res) => {
const { enabled } = req.body ?? {};
if (typeof enabled !== 'boolean') {
res.status(400).json({ error: 'enabled must be a boolean' });
return;
}
save(deps, { ...req.customer!, emailInvoicing: enabled });
res.json({ ok: true, emailInvoicing: enabled });
});
return router;
}

173
src/pricing.ts Normal file
View file

@ -0,0 +1,173 @@
export type PriceRule =
| { kind: 'free' }
| { kind: 'fixed'; fixedCents: number }
| { kind: 'variable'; baseCents: number; perKbCents: number; perMbCents: number };
export interface TierConfig {
id: string;
name: string;
multiplier: number;
monthlyCreditCents: number;
defaultRule?: PriceRule;
}
export interface RateCard {
endpoints: Record<string, PriceRule>;
}
export interface TierCatalog {
find(id: string): TierConfig | undefined;
list(): TierConfig[];
}
export class ConfigTierCatalog implements TierCatalog {
constructor(private tiers: TierConfig[]) {}
find(id: string): TierConfig | undefined {
return this.tiers.find((t) => t.id === id);
}
list(): TierConfig[] {
return [...this.tiers];
}
}
export interface CallUsage {
metadataBytes: number;
attachmentBytes: number;
}
export interface Quote {
endpointId: string;
listCents: number;
totalCents: number;
breakdown: { baseCents: number; metadataCents: number; attachmentCents: number };
}
export interface PricingContext {
rateCard: RateCard;
tiers: TierCatalog;
}
export interface PricingStore {
getRateCard(): RateCard;
getTiers(): TierConfig[];
upsertEndpoint(endpointId: string, rule: PriceRule): void;
deleteEndpoint(endpointId: string): void;
upsertTier(tier: TierConfig): void;
deleteTier(tierId: string): void;
}
export const DEFAULT_RATE_CARD: RateCard = {
endpoints: {
status: { kind: 'free' },
'storage-list': { kind: 'free' },
transform: { kind: 'fixed', fixedCents: 4 },
add: { kind: 'free' },
timestamp: { kind: 'fixed', fixedCents: 4 },
'hash-lookup': { kind: 'free' },
receipt: { kind: 'free' },
storage: { kind: 'variable', baseCents: 10, perKbCents: 1, perMbCents: 50 },
},
};
export const DEFAULT_TIERS: TierConfig[] = [
{ id: 'free', name: 'Free', multiplier: 1, monthlyCreditCents: 100 },
{
id: 'pro',
name: 'Pro',
multiplier: 0.5,
monthlyCreditCents: 1000,
defaultRule: { kind: 'fixed', fixedCents: 8 },
},
{
id: 'business',
name: 'Business',
multiplier: 0.25,
monthlyCreditCents: 10000,
defaultRule: { kind: 'fixed', fixedCents: 8 },
},
];
export const DEFAULT_PRICING: PricingContext = {
rateCard: DEFAULT_RATE_CARD,
tiers: new ConfigTierCatalog(DEFAULT_TIERS),
};
export class InMemoryPricingStore implements PricingStore {
private endpoints: Record<string, PriceRule>;
private tiers: TierConfig[];
constructor(rateCard: RateCard = DEFAULT_RATE_CARD, tiers: TierConfig[] = DEFAULT_TIERS) {
this.endpoints = { ...rateCard.endpoints };
this.tiers = [...tiers];
}
getRateCard(): RateCard {
return { endpoints: { ...this.endpoints } };
}
getTiers(): TierConfig[] {
return [...this.tiers];
}
upsertEndpoint(endpointId: string, rule: PriceRule): void {
this.endpoints[endpointId] = rule;
}
deleteEndpoint(endpointId: string): void {
delete this.endpoints[endpointId];
}
upsertTier(tier: TierConfig): void {
const i = this.tiers.findIndex((t) => t.id === tier.id);
if (i >= 0) this.tiers[i] = tier;
else this.tiers.push(tier);
}
deleteTier(tierId: string): void {
this.tiers = this.tiers.filter((t) => t.id !== tierId);
}
}
export function quoteCall(
pricing: PricingContext,
tierId: string,
endpointId: string,
usage: CallUsage,
multiplierOverride?: number,
): Quote {
const tier = pricing.tiers.find(tierId);
if (!tier) throw new Error(`Unknown tier: ${tierId}`);
const rule = pricing.rateCard.endpoints[endpointId] ?? tier.defaultRule;
if (!rule) throw new Error(`No price rule for ${tierId}/${endpointId}`);
if (rule.kind === 'free') {
return {
endpointId,
listCents: 0,
totalCents: 0,
breakdown: { baseCents: 0, metadataCents: 0, attachmentCents: 0 },
};
}
let breakdown: Quote['breakdown'];
if (rule.kind === 'fixed') {
breakdown = { baseCents: rule.fixedCents, metadataCents: 0, attachmentCents: 0 };
} else {
breakdown = {
baseCents: rule.baseCents,
metadataCents: Math.ceil(usage.metadataBytes / 1024) * rule.perKbCents,
attachmentCents:
Math.ceil(usage.attachmentBytes / (1024 * 1024)) * rule.perMbCents,
};
}
const listCents = breakdown.baseCents + breakdown.metadataCents + breakdown.attachmentCents;
const multiplier = multiplierOverride ?? tier.multiplier;
return {
endpointId,
listCents,
totalCents: Math.round(listCents * multiplier),
breakdown,
};
}

98
src/reports.ts Normal file
View file

@ -0,0 +1,98 @@
import { BillingType, Customer } from './auth';
import { TierConfig } from './pricing';
import { UsageEntry } from './usage';
export interface BillingRow {
customerId: string;
name: string;
billingType: BillingType;
calls: number;
totalCents: number;
creditCents: number;
billableCents: number;
}
/**
* Per-customer billing aggregation. Range is inclusive `from`, exclusive `to`;
* omit both for all time. Every customer matching the filters appears, even
* with zero usage.
*/
export function billingRows(args: {
entries: UsageEntry[];
customers: Customer[];
tiers: TierConfig[];
from?: Date;
to?: Date;
customerId?: string;
billingType?: BillingType;
}): BillingRow[] {
const { entries, customers, tiers, from, to, customerId, billingType } = args;
const inRange = entries.filter(
(e) => (!from || e.timestamp >= from) && (!to || e.timestamp < to),
);
return customers
.filter((c) => (customerId ? c.id === customerId : true))
.filter((c) => (billingType ? (c.billingType ?? 'stripe') === billingType : true))
.map((c) => {
const mine = inRange.filter((e) => e.customerId === c.id);
const totalCents = mine.reduce((sum, e) => sum + e.cents, 0);
const tier = tiers.find((t) => t.id === c.tierId);
const creditCents = Math.min(totalCents, tier?.monthlyCreditCents ?? 0);
return {
customerId: c.id,
name: c.name,
billingType: c.billingType ?? 'stripe',
calls: mine.length,
totalCents,
creditCents,
billableCents: totalCents - creditCents,
};
});
}
export interface TrendPoint {
/** Day bucket: YYYY-MM-DD. Week bucket: the Monday (UTC) of that week, YYYY-MM-DD. */
bucket: string;
calls: number;
cents: number;
}
export function usageTrend(entries: UsageEntry[], bucket: 'day' | 'week'): TrendPoint[] {
const key = (d: Date): string => {
const day = new Date(d);
day.setUTCHours(0, 0, 0, 0);
if (bucket === 'week') {
// Shift back to Monday (ISO weeks start Monday; getUTCDay: Sun=0).
const dow = (day.getUTCDay() + 6) % 7;
day.setUTCDate(day.getUTCDate() - dow);
}
return day.toISOString().slice(0, 10);
};
const buckets = new Map<string, TrendPoint>();
for (const e of entries) {
const k = key(e.timestamp);
const point = buckets.get(k) ?? { bucket: k, calls: 0, cents: 0 };
point.calls += 1;
point.cents += e.cents;
buckets.set(k, point);
}
return [...buckets.values()].sort((a, b) => a.bucket.localeCompare(b.bucket));
}
export interface CsvColumn {
key: string;
label: string;
}
/** RFC 4180 CSV with a header row. Values containing , " or newlines are quoted. */
export function toCsv<T extends object>(rows: T[], columns: CsvColumn[]): string {
const cell = (v: unknown): string => {
const s = String(v ?? '');
return /[",\n\r]/.test(s) ? `"${s.replace(/"/g, '""')}"` : s;
};
const lines = [columns.map((c) => cell(c.label)).join(',')];
for (const row of rows) {
lines.push(columns.map((c) => cell((row as Record<string, unknown>)[c.key])).join(','));
}
return lines.join('\n');
}

61
src/staff-iam.ts Normal file
View file

@ -0,0 +1,61 @@
export function iamUrl(): string {
return (process.env.STAFF_IAM_URL || '').replace(/\/$/, '');
}
export type StaffSession = {
username: string;
name?: string;
roles?: string[];
permissions: string[];
t: number;
};
export function allows(permissions: string[] | undefined, need: string | undefined): boolean {
if (!need) return true;
const list = permissions || [];
return list.includes('*') || list.includes(need);
}
export function permForAdminPath(method: string, p: string): string | undefined {
if (p === '/me' || p === '/login') return undefined;
if (p === '/pricing' || p.startsWith('/pricing')) return 'admin.pricing';
if (p.startsWith('/endpoints')) return 'admin.pricing';
if (p.startsWith('/tiers')) return 'admin.tiers';
if (p.startsWith('/customers') || p.startsWith('/credits')) return 'admin.customers';
if (p.startsWith('/statement')) return 'admin.statement';
if (p.startsWith('/invoices') || p.startsWith('/exports')) return 'admin.invoices';
if (p.startsWith('/reports')) return 'admin.reports';
if (p.startsWith('/zapier')) return 'admin.system';
if (p.startsWith('/users') || p.startsWith('/staff')) {
return method === 'GET' ? 'iam.users.read' : 'iam.users.write';
}
return undefined;
}
export async function iamLogin(
username: string,
password: string,
): Promise<{ token: string; user: StaffSession } | null> {
const base = iamUrl();
if (!base) return null;
const r = await fetch(`${base}/login`, {
method: 'POST',
headers: { 'content-type': 'application/json' },
body: JSON.stringify({ username, password }),
});
if (!r.ok) return null;
const data = (await r.json()) as {
token: string;
user: { username: string; name?: string; roles?: string[]; permissions: string[] };
};
return {
token: data.token,
user: {
username: data.user.username,
name: data.user.name,
roles: data.user.roles,
permissions: data.user.permissions || [],
t: Date.now(),
},
};
}

42
src/statement.ts Normal file
View file

@ -0,0 +1,42 @@
import { CreditAdjustment } from './credits';
import { Invoice } from './invoicing';
import { UsageEntry } from './usage';
export function composeStatement(args: {
customerId: string;
name?: string;
veraeUserId?: string;
prepaidCents: number;
credits: CreditAdjustment[];
usage: UsageEntry[];
invoices: Invoice[];
}) {
const payments = args.invoices
.filter((i) => i.customerId === args.customerId && (i.status === 'paid' || i.status === 'issued'))
.map((i) => ({
id: i.id,
customerId: i.customerId,
cents: i.billableCents,
kind: i.status === 'paid' ? 'invoice-paid' : 'invoice-issued',
reason: i.period,
at: new Date(i.paidAtMs || i.issuedAtMs || Date.now()).toISOString(),
}));
return {
customerId: args.customerId,
name: args.name,
veraeUserId: args.veraeUserId,
prepaidCents: args.prepaidCents,
credits: args.credits.filter((c) => c.customerId === args.customerId),
usage: args.usage
.filter((u) => u.customerId === args.customerId)
.slice(-50)
.reverse()
.map((u) => ({
customerId: u.customerId,
endpointId: u.endpointId,
cents: u.cents,
at: u.timestamp.toISOString(),
})),
payments,
};
}

47
src/upstream.ts Normal file
View file

@ -0,0 +1,47 @@
import { Request, Response } from 'express';
export type UpstreamFetch = typeof fetch;
/**
* After zappier meters the call, forward Verae operations to middleware.
* When ZAPPIER_UPSTREAM is unset, callers keep the local mock.
*/
export async function proxyVerae(
req: Request,
res: Response,
pathname: string,
fetchImpl: UpstreamFetch = fetch,
): Promise<boolean> {
const base = (process.env.ZAPPIER_UPSTREAM || '').replace(/\/$/, '');
if (!base) return false;
const url = new URL(pathname, `${base}/`);
for (const [k, v] of Object.entries(req.query)) {
if (typeof v === 'string') url.searchParams.set(k, v);
}
const headers: Record<string, string> = { accept: 'application/json' };
const key = req.header('x-api-key');
if (key) headers['x-api-key'] = key;
const auth = req.header('authorization');
if (auth) headers.authorization = auth;
const veraeUserId = req.customer?.veraeUserId;
if (veraeUserId) headers['x-verae-user-id'] = veraeUserId;
const method = req.method.toUpperCase();
const init: RequestInit = { method, headers };
if (method !== 'GET' && method !== 'HEAD') {
headers['content-type'] = 'application/json';
init.body = JSON.stringify(req.body ?? {});
}
const r = await fetchImpl(url.toString(), init);
const text = await r.text();
let body: unknown = text;
try {
body = text ? JSON.parse(text) : {};
} catch {
/* keep text */
}
if (body && typeof body === 'object' && !Array.isArray(body) && res.locals.quote) {
(body as Record<string, unknown>).quote = res.locals.quote;
}
res.status(r.status).json(body);
return true;
}

54
src/usage.ts Normal file
View file

@ -0,0 +1,54 @@
export interface UsageEntry {
customerId: string;
endpointId: string;
cents: number;
metadataBytes: number;
attachmentBytes: number;
timestamp: Date;
}
export interface UsageSummary {
customerId: string;
totalCents: number;
calls: number;
byEndpoint: Record<string, { calls: number; cents: number }>;
}
export function summarize(customerId: string, list: UsageEntry[]): UsageSummary {
const byEndpoint: UsageSummary['byEndpoint'] = {};
for (const e of list) {
const bucket = (byEndpoint[e.endpointId] ??= { calls: 0, cents: 0 });
bucket.calls += 1;
bucket.cents += e.cents;
}
return {
customerId,
calls: list.length,
totalCents: list.reduce((sum, e) => sum + e.cents, 0),
byEndpoint,
};
}
export interface UsageRepo {
record(entry: UsageEntry): void;
listFor(customerId: string, since?: Date): UsageEntry[];
summaryFor(customerId: string, since?: Date): UsageSummary;
}
export class InMemoryUsageRepo implements UsageRepo {
private entries: UsageEntry[] = [];
record(entry: UsageEntry): void {
this.entries.push(entry);
}
listFor(customerId: string, since?: Date): UsageEntry[] {
return this.entries.filter(
(e) => e.customerId === customerId && (!since || e.timestamp >= since),
);
}
summaryFor(customerId: string, since?: Date): UsageSummary {
return summarize(customerId, this.listFor(customerId, since));
}
}

89
src/verae-bind.ts Normal file
View file

@ -0,0 +1,89 @@
/**
* Bind a portal customer to a Verae central user id.
* The public credential stays the zappier API key. The Verae JWT never leaves the server.
*/
import { createHash, randomBytes } from 'crypto';
export function normalizeVeraeUsername(username: string): string {
return username.trim().toLowerCase();
}
export function stableVeraeUserId(username: string): string {
const n = normalizeVeraeUsername(username);
return `vu_${createHash('sha256').update(n).digest('hex').slice(0, 16)}`;
}
export type VeraeBind = {
veraeUserId: string;
veraeUsername: string;
bound: boolean;
};
function mockBind(email: string): VeraeBind {
const veraeUsername = normalizeVeraeUsername(email);
return { veraeUserId: stableVeraeUserId(veraeUsername), veraeUsername, bound: true };
}
/**
* Register or look up the customer on api.veraetime.net.
* MOCK_VERAE (default) or missing VERAE_API_BASE_URL stable id, no network.
*/
export async function bindVeraeUser(email: string, customerId?: string): Promise<VeraeBind> {
const identity = (process.env.IDENTITY_URL || '').replace(/\/$/, '');
if (identity) {
try {
const r = await fetch(`${identity}/bind`, {
method: 'POST',
headers: { 'content-type': 'application/json' },
body: JSON.stringify({ email, customerId }),
});
if (r.ok) return (await r.json()) as VeraeBind;
} catch {
/* fall through */
}
}
const mock = process.env.MOCK_VERAE !== 'false';
const base = (process.env.VERAE_API_BASE_URL || '').replace(/\/$/, '');
if (mock || !base) return mockBind(email);
const veraeUsername = normalizeVeraeUsername(email);
const adminUser = process.env.VERAE_ADMIN_USER;
const adminPass = process.env.VERAE_ADMIN_PASSWORD;
const password = `vt_${randomBytes(18).toString('base64url')}`;
const login = async (username: string, pass: string) => {
const r = await fetch(`${base}/auth/login`, {
method: 'POST',
headers: { 'content-type': 'application/json' },
body: JSON.stringify({ username, password: pass }),
});
if (!r.ok) throw new Error(`verae login ${r.status}`);
return r.json() as Promise<{ token: string; user?: { id?: string; username?: string } }>;
};
if (!adminUser || !adminPass) return mockBind(email);
try {
const admin = await login(adminUser, adminPass);
const created = await fetch(`${base}/auth/users`, {
method: 'POST',
headers: {
'content-type': 'application/json',
authorization: `Bearer ${admin.token}`,
},
body: JSON.stringify({ username: veraeUsername, password, role: 'user' }),
});
if (!created.ok && created.status !== 409) {
return mockBind(email);
}
const user = (await created.json().catch(() => ({}))) as { id?: string; username?: string };
const id = user.id || (await login(veraeUsername, password)).user?.id;
return {
veraeUserId: id || stableVeraeUserId(veraeUsername),
veraeUsername,
bound: true,
};
} catch {
return mockBind(email);
}
}

114
tests/accounts.test.ts Normal file
View file

@ -0,0 +1,114 @@
import {
base32Decode,
base32Encode,
generateTotpSecret,
hashPassword,
hotp,
InMemorySessionRepo,
totp,
totpUri,
verifyPassword,
verifyTotp,
} from '../src/accounts';
describe('password hashing (scrypt)', () => {
it('round-trips a correct password', () => {
const stored = hashPassword('correct horse battery staple');
expect(verifyPassword('correct horse battery staple', stored)).toBe(true);
});
it('rejects a wrong password', () => {
const stored = hashPassword('correct horse battery staple');
expect(verifyPassword('wrong', stored)).toBe(false);
});
it('uses a random salt per hash', () => {
expect(hashPassword('same')).not.toBe(hashPassword('same'));
});
it('rejects malformed stored hashes', () => {
expect(verifyPassword('x', 'not-a-hash')).toBe(false);
expect(verifyPassword('x', '')).toBe(false);
});
});
describe('base32', () => {
it('round-trips bytes', () => {
const buf = Buffer.from('hello world, this is base32');
expect(base32Decode(base32Encode(buf)).equals(buf)).toBe(true);
});
it('encodes without padding', () => {
expect(base32Encode(Buffer.from('f'))).toBe('MY');
expect(base32Encode(Buffer.from('fo'))).toBe('MZXQ');
});
});
describe('TOTP (RFC 6238)', () => {
// RFC 6238 SHA-1 seed, ASCII "12345678901234567890", base32-encoded.
const RFC_SEED_B32 = 'GEZDGNBVGY3TQOJQGEZDGNBVGY3TQOJQ';
it.each([
[59_000, '287082'],
[1_111_111_109_000, '081804'],
[1_234_567_890_000, '005924'],
])('produces the RFC 6-digit truncation at %ims', (atMs, expected) => {
expect(totp(RFC_SEED_B32, atMs)).toBe(expected);
});
it('hotp pads short codes to 6 digits', () => {
expect(hotp(RFC_SEED_B32, Math.floor(1_234_567_890 / 30))).toBe('005924');
});
it('verifyTotp accepts codes within the window and rejects outside', () => {
const at = 1_111_111_109_000;
const code = totp(RFC_SEED_B32, at);
expect(verifyTotp(RFC_SEED_B32, code, at)).toBe(true);
expect(verifyTotp(RFC_SEED_B32, code, at + 30_000)).toBe(true); // one step later, window 1
expect(verifyTotp(RFC_SEED_B32, code, at + 90_000)).toBe(false); // three steps later
});
it('verifyTotp rejects malformed codes', () => {
expect(verifyTotp(RFC_SEED_B32, '12345', 59_000)).toBe(false);
expect(verifyTotp(RFC_SEED_B32, 'abcdef', 59_000)).toBe(false);
});
it('generateTotpSecret returns a decodable 160-bit base32 secret', () => {
const secret = generateTotpSecret();
expect(secret).toMatch(/^[A-Z2-7]{32}$/);
expect(base32Decode(secret).length).toBe(20);
});
it('totpUri builds an otpauth URI', () => {
const uri = totpUri('ABC234', 'ada@example.com', 'Zappier');
expect(uri).toBe(
'otpauth://totp/Zappier:ada%40example.com?secret=ABC234&issuer=Zappier',
);
});
});
describe('InMemorySessionRepo', () => {
it('creates and retrieves sessions within the TTL', () => {
const repo = new InMemorySessionRepo();
const s = repo.create('cust_1', 60_000);
expect(s.customerId).toBe('cust_1');
expect(repo.get(s.token, s.createdMs + 30_000)?.customerId).toBe('cust_1');
});
it('expires sessions after the TTL', () => {
const repo = new InMemorySessionRepo();
const s = repo.create('cust_1', 60_000);
expect(repo.get(s.token, s.createdMs + 61_000)).toBeUndefined();
});
it('deletes sessions (logout)', () => {
const repo = new InMemorySessionRepo();
const s = repo.create('cust_1', 60_000);
repo.delete(s.token);
expect(repo.get(s.token, s.createdMs)).toBeUndefined();
});
it('returns undefined for unknown tokens', () => {
expect(new InMemorySessionRepo().get('nope')).toBeUndefined();
});
});

View file

@ -0,0 +1,207 @@
import request from 'supertest';
import { buildApp } from '../src/app';
import { UsageEntry } from '../src/usage';
const ADMIN = { 'x-admin-key': 'admin-dev-key' };
const entry = (customerId: string, endpointId: string, cents: number, iso: string): UsageEntry => ({
customerId,
endpointId,
cents,
metadataBytes: 0,
attachmentBytes: 0,
timestamp: new Date(iso),
});
function seededApp() {
const built = buildApp();
built.usage.record(entry('cust_2', 'transform', 3, '2026-07-05T10:00:00Z'));
built.usage.record(entry('cust_2', 'storage', 1500, '2026-07-06T10:00:00Z'));
built.usage.record(entry('cust_2', 'storage', 300, '2026-08-01T01:00:00Z'));
return built;
}
describe('admin accounting API', () => {
it('generates draft invoices for customers with usage, skips the rest', async () => {
const { app } = seededApp();
const res = await request(app)
.post('/admin/api/invoices/generate')
.set(ADMIN)
.send({ period: '2026-07' });
expect(res.status).toBe(200);
expect(res.body.generated).toEqual(['INV-2026-07-0001']);
expect(res.body.skipped.map((s: { customerId: string }) => s.customerId)).toEqual(
expect.arrayContaining(['cust_1', 'cust_3']),
);
const inv = await request(app).get('/admin/api/invoices/INV-2026-07-0001').set(ADMIN);
expect(inv.body.lines).toEqual([
{ endpointId: 'storage', calls: 1, cents: 1500 },
{ endpointId: 'transform', calls: 1, cents: 3 },
]);
expect(inv.body.totalCents).toBe(1503);
expect(inv.body.billableCents).toBe(503); // Pro credit 1000
});
it('regenerating a draft keeps the id; issued invoices are skipped', async () => {
const { app, usage } = seededApp();
await request(app).post('/admin/api/invoices/generate').set(ADMIN).send({ period: '2026-07' });
usage.record(entry('cust_2', 'transform', 3, '2026-07-20T10:00:00Z'));
const again = await request(app)
.post('/admin/api/invoices/generate')
.set(ADMIN)
.send({ period: '2026-07' });
expect(again.body.generated).toEqual(['INV-2026-07-0001']);
const inv = await request(app).get('/admin/api/invoices/INV-2026-07-0001').set(ADMIN);
expect(inv.body.totalCents).toBe(1506);
await request(app).post('/admin/api/invoices/INV-2026-07-0001/issue').set(ADMIN);
const third = await request(app)
.post('/admin/api/invoices/generate')
.set(ADMIN)
.send({ period: '2026-07' });
expect(third.body.generated).toEqual([]);
expect(third.body.skipped).toEqual(
expect.arrayContaining([expect.objectContaining({ customerId: 'cust_2' })]),
);
});
it('walks the lifecycle draft → issued → paid and rejects illegal transitions', async () => {
const { app } = seededApp();
await request(app).post('/admin/api/invoices/generate').set(ADMIN).send({ period: '2026-07' });
const payDraft = await request(app).post('/admin/api/invoices/INV-2026-07-0001/paid').set(ADMIN);
expect(payDraft.status).toBe(409);
const issue = await request(app).post('/admin/api/invoices/INV-2026-07-0001/issue').set(ADMIN);
expect(issue.status).toBe(200);
expect(issue.body.status).toBe('issued');
expect(issue.body.issuedAtMs).toEqual(expect.any(Number));
const reissue = await request(app).post('/admin/api/invoices/INV-2026-07-0001/issue').set(ADMIN);
expect(reissue.status).toBe(409);
const paid = await request(app).post('/admin/api/invoices/INV-2026-07-0001/paid').set(ADMIN);
expect(paid.status).toBe(200);
expect(paid.body.status).toBe('paid');
expect(paid.body.paidAtMs).toEqual(expect.any(Number));
});
it('sets a due date on issued purchase-order invoices', async () => {
const { app } = seededApp();
await request(app)
.put('/admin/api/customers/cust_2')
.set(ADMIN)
.send({ billingType: 'purchase_order' });
await request(app)
.post('/admin/api/invoices/generate')
.set(ADMIN)
.send({ period: '2026-07', poNumber: 'PO-77' });
const issue = await request(app).post('/admin/api/invoices/INV-2026-07-0001/issue').set(ADMIN);
expect(issue.body.poNumber).toBe('PO-77');
expect(issue.body.dueAtMs - issue.body.issuedAtMs).toBe(30 * 24 * 60 * 60 * 1000);
});
it('rejects a malformed period with 400', async () => {
const { app } = seededApp();
const res = await request(app)
.post('/admin/api/invoices/generate')
.set(ADMIN)
.send({ period: 'July 2026' });
expect(res.status).toBe(400);
});
it('lists invoices with filters and 404s unknown ids', async () => {
const { app } = seededApp();
await request(app).post('/admin/api/invoices/generate').set(ADMIN).send({ period: '2026-07' });
const list = await request(app)
.get('/admin/api/invoices?customerId=cust_2&status=draft')
.set(ADMIN);
expect(list.body.invoices).toHaveLength(1);
const empty = await request(app).get('/admin/api/invoices?status=paid').set(ADMIN);
expect(empty.body.invoices).toHaveLength(0);
const missing = await request(app).get('/admin/api/invoices/INV-1999-01-0001').set(ADMIN);
expect(missing.status).toBe(404);
});
it('renders a print-ready HTML invoice', async () => {
const { app } = seededApp();
await request(app).post('/admin/api/invoices/generate').set(ADMIN).send({ period: '2026-07' });
const res = await request(app)
.get('/admin/api/invoices/INV-2026-07-0001?format=html')
.set(ADMIN);
expect(res.status).toBe(200);
expect(res.headers['content-type']).toContain('text/html');
expect(res.text).toContain('INV-2026-07-0001');
expect(res.text).toContain('Grace (pro)');
});
it('serves the billing report as JSON and CSV with filters', async () => {
const { app } = seededApp();
const json = await request(app)
.get('/admin/api/reports/billing?from=2026-07-01&to=2026-08-01')
.set(ADMIN);
expect(json.status).toBe(200);
const grace = json.body.rows.find((r: { customerId: string }) => r.customerId === 'cust_2');
expect(grace).toMatchObject({ calls: 2, totalCents: 1503, billableCents: 503 });
const csv = await request(app)
.get('/admin/api/reports/billing?format=csv&customerId=cust_2')
.set(ADMIN);
expect(csv.headers['content-type']).toContain('text/csv');
expect(csv.text.split('\n')[0]).toBe('Customer Id,Name,Billing Type,Calls,Total Cents,Credit Cents,Billable Cents');
expect(csv.text).toContain('cust_2,Grace (pro),stripe,3,1803,1000,803');
await request(app)
.put('/admin/api/customers/cust_3')
.set(ADMIN)
.send({ billingType: 'purchase_order' });
const poOnly = await request(app)
.get('/admin/api/reports/billing?billingType=purchase_order')
.set(ADMIN);
expect(poOnly.body.rows.map((r: { customerId: string }) => r.customerId)).toEqual(['cust_3']);
});
it('serves the usage trend', async () => {
const { app } = seededApp();
const res = await request(app)
.get('/admin/api/reports/usage-trend?bucket=day')
.set(ADMIN);
expect(res.status).toBe(200);
expect(res.body.points).toEqual([
{ bucket: '2026-07-05', calls: 1, cents: 3 },
{ bucket: '2026-07-06', calls: 1, cents: 1500 },
{ bucket: '2026-08-01', calls: 1, cents: 300 },
]);
});
it('reports Zapier app status from the integration directory', async () => {
const { app } = seededApp();
const res = await request(app).get('/admin/api/zapier/status').set(ADMIN);
expect(res.status).toBe(200);
expect(res.body.triggers).toEqual(['new_item']);
expect(res.body.creates).toEqual(['store_data']);
});
it('draws down the prepaid balance at issue; full coverage marks the invoice paid', async () => {
const { app, customers } = seededApp();
const grace = customers.findByApiKey('key-grace')!;
customers.save({ ...grace, balanceCents: 600 });
await request(app).post('/admin/api/invoices/generate').set(ADMIN).send({ period: '2026-07' });
// billable is 503c after the Pro credit
const issue = await request(app).post('/admin/api/invoices/INV-2026-07-0001/issue').set(ADMIN);
expect(issue.status).toBe(200);
expect(issue.body.status).toBe('paid');
expect(issue.body.paidAtMs).toEqual(expect.any(Number));
expect(customers.findByApiKey('key-grace')?.balanceCents).toBe(97);
});
it('leaves the invoice issued and the balance untouched when coverage is partial', async () => {
const { app, customers } = seededApp();
const grace = customers.findByApiKey('key-grace')!;
customers.save({ ...grace, balanceCents: 100 });
await request(app).post('/admin/api/invoices/generate').set(ADMIN).send({ period: '2026-07' });
const issue = await request(app).post('/admin/api/invoices/INV-2026-07-0001/issue').set(ADMIN);
expect(issue.status).toBe(200);
expect(issue.body.status).toBe('issued');
expect(customers.findByApiKey('key-grace')?.balanceCents).toBe(100);
});
});

Some files were not shown because too many files have changed in this diff Show more