Initial import of zappier-edge from zapier monorepo
This commit is contained in:
commit
0c014036d6
131 changed files with 21064 additions and 0 deletions
BIN
.DS_Store
vendored
Normal file
BIN
.DS_Store
vendored
Normal file
Binary file not shown.
5
.env.example
Normal file
5
.env.example
Normal 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
5
.gitignore
vendored
Normal file
|
|
@ -0,0 +1,5 @@
|
||||||
|
node_modules/
|
||||||
|
dist/
|
||||||
|
.env
|
||||||
|
zappier.db
|
||||||
|
zappier.db-journal
|
||||||
12
Dockerfile
Normal file
12
Dockerfile
Normal 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
10
NATS.md
Normal 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
142
README.md
Normal 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
11
SUMMARY.md
Normal file
|
|
@ -0,0 +1,11 @@
|
||||||
|
# zappier-edge
|
||||||
|
|
||||||
|
**Job:** Metered commercial API, portal, admin, Stripe. Zapier’s 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
810
admin/app.js
Normal 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
283
admin/index.html
Normal 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">▦ Rate card</button>
|
||||||
|
<button data-tab="tiers">◈ Customer types</button>
|
||||||
|
<button data-tab="customers">☺ Customers</button>
|
||||||
|
<button data-tab="statement">☰ Statement</button>
|
||||||
|
<button data-tab="invoices">▤ Invoices</button>
|
||||||
|
<button data-tab="reports">↗ Reports</button>
|
||||||
|
<button data-tab="system">⚙ System</button>
|
||||||
|
<button data-tab="users">♟ 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
BIN
docs/.DS_Store
vendored
Normal file
Binary file not shown.
126
docs/ACCOUNTING.md
Normal file
126
docs/ACCOUNTING.md
Normal 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`).
|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 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 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**.
|
||||||
|
|
||||||
|

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

|
||||||
|
|
||||||
|
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?"
|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
|
- **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.
|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 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
BIN
docs/ACCOUNTING.pdf
Normal file
Binary file not shown.
98
docs/CUSTOMER-PORTAL.md
Normal file
98
docs/CUSTOMER-PORTAL.md
Normal 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**.
|
||||||
|
|
||||||
|

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

|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2. Dashboard
|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
|
- **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.
|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
|
The interactive API reference (Swagger UI) is linked at the top (`/docs`).
|
||||||
|
|
||||||
|
## 4. Invoices
|
||||||
|
|
||||||
|

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

|
||||||
|
|
||||||
|
Only your own invoices are visible; other customers' ids return "not found".
|
||||||
|
|
||||||
|
## 5. Billing: reloads & email invoicing
|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
|
- **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**:
|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
|
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
BIN
docs/CUSTOMER-PORTAL.pdf
Normal file
Binary file not shown.
406
docs/DEVELOPER.md
Normal file
406
docs/DEVELOPER.md
Normal 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
BIN
docs/DEVELOPER.pdf
Normal file
Binary file not shown.
111
docs/USER-MANAGEMENT.md
Normal file
111
docs/USER-MANAGEMENT.md
Normal 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:
|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
|
- **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.
|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
|
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:
|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
|
- **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:
|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
|
- **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
BIN
docs/USER-MANAGEMENT.pdf
Normal file
Binary file not shown.
348
docs/USER-MANUAL.md
Normal file
348
docs/USER-MANUAL.md
Normal 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:3021/portal/` (public door; loopback edge still serves `/portal` on `:3000`) | End-user customers (self-service) |
|
||||||
|
| **Zapier app** | `zapier-app/` directory | No-code users via Zapier |
|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 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
|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
|
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
|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
|
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
|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
|
- **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).
|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 4A. Customer portal
|
||||||
|
|
||||||
|
Your customers self-serve at `http://localhost:3021/portal/` (access-web; loopback edge `/portal` remains on `:3000`): 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 1–3 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
BIN
docs/USER-MANUAL.pdf
Normal file
Binary file not shown.
Some files were not shown because too many files have changed in this diff Show more
Loading…
Reference in a new issue