Milestone 0: import zappier billing, Verae middleware, and Zapier research

Compose-ready workspace: packages/zappier (rate card, portal, Stripe),
packages/verae-zapier-middleware (timestamp + NATS), packages/verae-zapier
(CLI app), vendor/zapier-platform, and research/zapier vendor corpus.

Gate 0 structure checks pass. Product code and research are not yet wired.
This commit is contained in:
George Lambert 2026-09-09 02:37:36 -04:00
commit b4150c8250
1364 changed files with 6814366 additions and 0 deletions

View file

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

View file

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

View file

@ -0,0 +1,485 @@
import { randomBytes, randomUUID } from 'crypto';
import fs from 'fs';
import path from 'path';
import { RequestHandler, Router } from 'express';
import { verifyPassword } from './accounts';
import { AdminUserRepo, makeAdminUser } from './admin-users';
import { CustomerRepo } from './auth';
import { buildInvoice, Invoice, InvoiceRepo } from './invoicing';
import { PriceRule, PricingStore, TierConfig } from './pricing';
import { PROJECT_ROOT } from './paths';
import { billingRows, toCsv, usageTrend } from './reports';
import { UsageRepo } from './usage';
// Issued login tokens (in-memory; a restart simply requires logging in again).
const sessions = new Map<string, number>();
export function adminLoginRouter(users: AdminUserRepo): Router {
const router = Router();
router.post('/login', (req, res) => {
const { username, password } = req.body ?? {};
if (typeof username !== 'string' || typeof password !== 'string') {
res.status(401).json({ error: 'invalid username or password' });
return;
}
const user = users.findByUsername(username);
if (!user || !user.active || !verifyPassword(password, user.passwordHash)) {
res.status(401).json({ error: 'invalid username or password' });
return;
}
const token = randomBytes(24).toString('hex');
sessions.set(token, Date.now());
res.json({ token });
});
return router;
}
export function adminAuth(): RequestHandler {
return (req, res, next) => {
const expected = process.env.ADMIN_KEY ?? 'admin-dev-key';
if (req.header('x-admin-key') === expected) return next();
const bearer = req.header('authorization');
const token = bearer?.startsWith('Bearer ') ? bearer.slice(7) : undefined;
if (token && sessions.has(token)) return next();
res.status(403).json({ error: 'invalid or missing admin key' });
};
}
function isValidRule(rule: unknown): rule is PriceRule {
if (!rule || typeof rule !== 'object') return false;
const r = rule as Record<string, unknown>;
if (r.kind === 'free') return true;
if (r.kind === 'fixed') return typeof r.fixedCents === 'number';
if (r.kind === 'variable') {
return ['baseCents', 'perKbCents', 'perMbCents'].every((k) => typeof r[k] === 'number');
}
return false;
}
function isValidTier(tier: unknown): tier is TierConfig {
if (!tier || typeof tier !== 'object') return false;
const t = tier as Record<string, unknown>;
return (
typeof t.name === 'string' &&
typeof t.multiplier === 'number' &&
typeof t.monthlyCreditCents === 'number' &&
(t.defaultRule === undefined || isValidRule(t.defaultRule))
);
}
export interface AccountingDeps {
usage: UsageRepo;
invoices: InvoiceRepo;
users: AdminUserRepo;
}
const PERIOD_RE = /^\d{4}-(0[1-9]|1[0-2])$/;
function periodWindow(period: string): { from: Date; to: Date } {
const [y, m] = period.split('-').map(Number);
return { from: new Date(Date.UTC(y, m - 1, 1)), to: new Date(Date.UTC(y, m, 1)) };
}
function parseDate(value: unknown): Date | undefined {
if (typeof value !== 'string' || value.trim() === '') return undefined;
const d = new Date(value);
return Number.isNaN(d.getTime()) ? undefined : d;
}
function escapeHtml(s: string): string {
return s
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;');
}
const dollars = (cents: number): string => `$${(cents / 100).toFixed(2)}`;
export function renderInvoiceHtml(invoice: Invoice, customerName: string): string {
const rows = invoice.lines
.map(
(l) =>
`<tr><td>${escapeHtml(l.endpointId)}</td><td>${l.calls}</td><td>${dollars(l.cents)}</td></tr>`,
)
.join('');
const po = invoice.poNumber
? `<p><strong>Purchase order:</strong> ${escapeHtml(invoice.poNumber)}</p>`
: '';
const due = invoice.dueAtMs ? new Date(invoice.dueAtMs).toISOString().slice(0, 10) : '—';
return `<!doctype html><html><head><meta charset="utf-8"><title>${invoice.id}</title>
<style>
body { font-family: -apple-system, "Segoe UI", sans-serif; max-width: 720px; margin: 2rem auto; color: #171a26; }
h1 { font-size: 1.4rem; margin-bottom: 0; }
.muted { color: #6b7186; }
table { border-collapse: collapse; width: 100%; margin: 1.5rem 0; }
th, td { border-bottom: 1px solid #e5e7f0; text-align: left; padding: 0.5rem 0.6rem; }
th { font-size: 0.75rem; text-transform: uppercase; color: #6b7186; }
.totals td { border: 0; padding: 0.2rem 0.6rem; }
.totals .grand { font-weight: 700; font-size: 1.1rem; border-top: 2px solid #171a26; }
.status { display: inline-block; padding: 0.15rem 0.7rem; border-radius: 999px; background: #eef0fe; color: #4f46e5; font-weight: 700; font-size: 0.8rem; }
@media print { body { margin: 0; } }
</style></head><body>
<h1>Invoice ${escapeHtml(invoice.id)}</h1>
<p class="muted">Zappier API usage · period ${escapeHtml(invoice.period)} · due ${due}</p>
<p><span class="status">${invoice.status.toUpperCase()}</span></p>
<p><strong>Billed to:</strong> ${escapeHtml(customerName)} (${escapeHtml(invoice.customerId)})<br>
<strong>Billing type:</strong> ${escapeHtml(invoice.billingType)}</p>
${po}
<table><thead><tr><th>Endpoint</th><th>Calls</th><th>Amount</th></tr></thead><tbody>${rows}</tbody></table>
<table class="totals">
<tr><td>Usage total</td><td>${dollars(invoice.totalCents)}</td></tr>
<tr><td>Monthly credit</td><td>${dollars(invoice.creditCents)}</td></tr>
<tr class="grand"><td>Amount due</td><td>${dollars(invoice.billableCents)}</td></tr>
</table>
</body></html>`;
}
export function adminRouter(
store: PricingStore,
customers: CustomerRepo,
accounting: AccountingDeps,
): Router {
const router = Router();
router.get('/pricing', (req, res) => {
res.json({ rateCard: store.getRateCard(), tiers: store.getTiers() });
});
router.put('/endpoints/:id', (req, res) => {
if (!isValidRule(req.body)) {
res.status(400).json({ error: 'invalid price rule' });
return;
}
store.upsertEndpoint(req.params.id, req.body);
res.json({ ok: true });
});
router.delete('/endpoints/:id', (req, res) => {
store.deleteEndpoint(req.params.id);
res.json({ ok: true });
});
router.put('/tiers/:id', (req, res) => {
if (!isValidTier(req.body)) {
res.status(400).json({ error: 'invalid tier config' });
return;
}
store.upsertTier({ ...req.body, id: req.params.id });
res.json({ ok: true });
});
router.delete('/tiers/:id', (req, res) => {
store.deleteTier(req.params.id);
res.json({ ok: true });
});
router.get('/customers', (req, res) => {
res.json({ customers: customers.list().map((c) => ({ ...c, apiKey: undefined })) });
});
router.post('/customers', (req, res) => {
const { name, tierId } = req.body ?? {};
if (typeof name !== 'string' || typeof tierId !== 'string') {
res.status(400).json({ error: 'name and tierId required' });
return;
}
const customer = {
id: `cust_${randomUUID().slice(0, 8)}`,
name,
tierId,
apiKey: `key-${randomBytes(12).toString('hex')}`,
};
customers.save(customer);
res.status(201).json(customer);
});
router.put('/customers/:id', (req, res) => {
const existing = customers.list().find((c) => c.id === req.params.id);
if (!existing) {
res.status(404).json({ error: 'customer not found' });
return;
}
const { name, tierId, multiplierOverride, stripeCustomerId, billingType, email } =
req.body ?? {};
if (
billingType !== undefined &&
billingType !== 'stripe' &&
billingType !== 'purchase_order'
) {
res.status(400).json({ error: 'billingType must be stripe or purchase_order' });
return;
}
customers.save({
...existing,
...(name !== undefined ? { name } : {}),
...(tierId !== undefined ? { tierId } : {}),
...(multiplierOverride !== undefined ? { multiplierOverride } : {}),
...(stripeCustomerId !== undefined ? { stripeCustomerId } : {}),
...(billingType !== undefined ? { billingType } : {}),
...(email !== undefined ? { email } : {}),
});
res.json({ ok: true });
});
/* ---------------- accounting: invoices ---------------- */
router.post('/invoices/generate', (req, res) => {
const { period, customerId, poNumber } = req.body ?? {};
if (typeof period !== 'string' || !PERIOD_RE.test(period)) {
res.status(400).json({ error: 'period must be YYYY-MM' });
return;
}
const { from, to } = periodWindow(period);
const tiers = store.getTiers();
const generated: string[] = [];
const skipped: { customerId: string; reason: string }[] = [];
for (const customer of customers.list()) {
if (customerId && customer.id !== customerId) continue;
const existing = accounting.invoices
.list({ customerId: customer.id, period })
.find((i) => i.status !== 'draft');
if (existing) {
skipped.push({ customerId: customer.id, reason: `${existing.id} already ${existing.status}` });
continue;
}
const entries = accounting.usage
.listFor(customer.id, from)
.filter((e) => e.timestamp < to);
if (entries.length === 0) {
skipped.push({ customerId: customer.id, reason: 'no usage in period' });
continue;
}
const tier = tiers.find((t) => t.id === customer.tierId);
if (!tier) {
skipped.push({ customerId: customer.id, reason: `unknown tier ${customer.tierId}` });
continue;
}
const draft = accounting.invoices.list({ customerId: customer.id, period })[0];
const invoice = buildInvoice({
customer,
period,
sequence: draft ? Number(draft.id.slice(-4)) : accounting.invoices.nextSequence(period),
entries,
tier,
...(poNumber !== undefined ? { poNumber } : draft?.poNumber !== undefined ? { poNumber: draft.poNumber } : {}),
});
if (draft) invoice.id = draft.id;
accounting.invoices.save(invoice);
generated.push(invoice.id);
}
res.json({ generated, skipped });
});
router.get('/invoices', (req, res) => {
const { customerId, period, status } = req.query;
res.json({
invoices: accounting.invoices.list({
...(typeof customerId === 'string' ? { customerId } : {}),
...(typeof period === 'string' ? { period } : {}),
...(typeof status === 'string' ? { status: status as Invoice['status'] } : {}),
}),
});
});
router.get('/invoices/:id', (req, res) => {
const invoice = accounting.invoices.get(req.params.id);
if (!invoice) {
res.status(404).json({ error: 'invoice not found' });
return;
}
if (req.query.format === 'html') {
const name = customers.list().find((c) => c.id === invoice.customerId)?.name ?? invoice.customerId;
res.type('html').send(renderInvoiceHtml(invoice, name));
return;
}
res.json(invoice);
});
router.post('/invoices/:id/issue', (req, res) => {
const invoice = accounting.invoices.get(req.params.id);
if (!invoice) {
res.status(404).json({ error: 'invoice not found' });
return;
}
if (invoice.status !== 'draft') {
res.status(409).json({ error: `invoice is ${invoice.status}, not draft` });
return;
}
const now = Date.now();
const issued: Invoice = {
...invoice,
status: 'issued',
issuedAtMs: now,
...(invoice.billingType === 'purchase_order'
? { dueAtMs: now + 30 * 24 * 60 * 60 * 1000 }
: {}),
};
// Prepaid drawdown: a balance that fully covers the billable amount is
// deducted and the invoice goes straight to paid. Partial coverage stays
// untouched (no partial payments).
const customer = customers.list().find((c) => c.id === invoice.customerId);
const balance = customer?.balanceCents ?? 0;
if (customer && invoice.billableCents > 0 && balance >= invoice.billableCents) {
customers.save({ ...customer, balanceCents: balance - invoice.billableCents });
const paid: Invoice = { ...issued, status: 'paid', paidAtMs: now };
accounting.invoices.save(paid);
res.json(paid);
return;
}
accounting.invoices.save(issued);
res.json(issued);
});
router.post('/invoices/:id/paid', (req, res) => {
const invoice = accounting.invoices.get(req.params.id);
if (!invoice) {
res.status(404).json({ error: 'invoice not found' });
return;
}
if (invoice.status !== 'issued') {
res.status(409).json({ error: `invoice is ${invoice.status}, not issued` });
return;
}
const paid: Invoice = { ...invoice, status: 'paid', paidAtMs: Date.now() };
accounting.invoices.save(paid);
res.json(paid);
});
/* ---------------- accounting: reports ---------------- */
router.get('/reports/billing', (req, res) => {
const all = customers.list().flatMap((c) => accounting.usage.listFor(c.id));
const rows = billingRows({
entries: all,
customers: customers.list(),
tiers: store.getTiers(),
...(parseDate(req.query.from) ? { from: parseDate(req.query.from)! } : {}),
...(parseDate(req.query.to) ? { to: parseDate(req.query.to)! } : {}),
...(typeof req.query.customerId === 'string' ? { customerId: req.query.customerId } : {}),
...(typeof req.query.billingType === 'string'
? { billingType: req.query.billingType as 'stripe' | 'purchase_order' }
: {}),
});
if (req.query.format === 'csv') {
const csv = toCsv(rows, [
{ key: 'customerId', label: 'Customer Id' },
{ key: 'name', label: 'Name' },
{ key: 'billingType', label: 'Billing Type' },
{ key: 'calls', label: 'Calls' },
{ key: 'totalCents', label: 'Total Cents' },
{ key: 'creditCents', label: 'Credit Cents' },
{ key: 'billableCents', label: 'Billable Cents' },
]);
res
.type('text/csv')
.set('content-disposition', 'attachment; filename="billing-report.csv"')
.send(csv);
return;
}
res.json({ rows });
});
router.get('/reports/usage-trend', (req, res) => {
const from = parseDate(req.query.from);
const to = parseDate(req.query.to);
const customerId = typeof req.query.customerId === 'string' ? req.query.customerId : undefined;
const entries = customers
.list()
.filter((c) => (customerId ? c.id === customerId : true))
.flatMap((c) => accounting.usage.listFor(c.id))
.filter((e) => (!from || e.timestamp >= from) && (!to || e.timestamp < to));
const bucket = req.query.bucket === 'week' ? 'week' : 'day';
res.json({ points: usageTrend(entries, bucket) });
});
/* ---------------- admin user management ---------------- */
const USERNAME_RE = /^[a-zA-Z0-9_.-]+$/;
router.get('/users', (req, res) => {
res.json({
users: accounting.users.list().map((u) => ({
id: u.id,
username: u.username,
active: u.active,
createdMs: u.createdMs,
})),
});
});
router.post('/users', (req, res) => {
const { username, password } = req.body ?? {};
if (typeof username !== 'string' || !USERNAME_RE.test(username)) {
res.status(400).json({ error: 'username must match [a-zA-Z0-9_.-]+' });
return;
}
if (typeof password !== 'string' || password.length < 8) {
res.status(400).json({ error: 'password must be at least 8 characters' });
return;
}
if (accounting.users.findByUsername(username)) {
res.status(409).json({ error: 'username already exists' });
return;
}
const user = makeAdminUser(username, password);
accounting.users.save(user);
res.status(201).json({
id: user.id,
username: user.username,
active: user.active,
createdMs: user.createdMs,
});
});
const setActive = (active: boolean): RequestHandler => (req, res) => {
const user = accounting.users.findByUsername(req.params.username);
if (!user) {
res.status(404).json({ error: 'admin user not found' });
return;
}
if (!active) {
const activeCount = accounting.users.list().filter((u) => u.active).length;
if (user.active && activeCount <= 1) {
res.status(400).json({ error: 'cannot deactivate the last active admin user' });
return;
}
}
accounting.users.save({ ...user, active });
res.json({ ok: true });
};
router.post('/users/:username/deactivate', setActive(false));
router.post('/users/:username/activate', setActive(true));
/* ---------------- system: Zapier integration status ---------------- */
router.get('/zapier/status', (req, res) => {
const dir = path.join(PROJECT_ROOT, 'zapier-app');
const readDir = (sub: string): string[] => {
try {
return fs
.readdirSync(path.join(dir, sub))
.filter((f) => f.endsWith('.js'))
.map((f) => f.replace(/\.js$/, ''));
} catch {
return [];
}
};
let version: string | undefined;
try {
version = JSON.parse(fs.readFileSync(path.join(dir, 'package.json'), 'utf8')).version;
} catch {
version = undefined;
}
res.json({
appDirPresent: fs.existsSync(dir),
version,
triggers: readDir('triggers'),
creates: readDir('creates'),
});
});
return router;
}

192
packages/zappier/src/app.ts Normal file
View file

@ -0,0 +1,192 @@
import path from 'path';
import { randomUUID } from 'crypto';
import express, { Express, NextFunction, Request, RequestHandler, Response } from 'express';
import * as OpenApiValidator from 'express-openapi-validator';
import swaggerUi from 'swagger-ui-express';
import YAML from 'yamljs';
import {
apiKeyAuth,
Customer,
CustomerRepo,
InMemoryCustomerRepo,
} from './auth';
import { meter } from './meter';
import { applyMonthlyCredit } from './billing/credit';
import {
ConfigTierCatalog,
InMemoryPricingStore,
PricingContext,
PricingStore,
} from './pricing';
import { InMemoryUsageRepo, UsageRepo } from './usage';
import { adminAuth, adminLoginRouter, adminRouter } from './admin';
import { AdminUserRepo, InMemoryAdminUserRepo, seedAdminUsersFromEnv } from './admin-users';
import { InMemorySessionRepo, SessionRepo } from './accounts';
import { InMemoryInvoiceRepo, InvoiceRepo } from './invoicing';
import { PROJECT_ROOT } from './paths';
import { PaymentClient, portalRouter } from './portal';
export interface StoredItem {
id: string;
customerId: string;
metadata: Record<string, unknown>;
attachments: { filename: string; size: number }[];
createdAt: string;
}
export const DEFAULT_CUSTOMERS: Customer[] = [
{ id: 'cust_1', name: 'Ada (free)', tierId: 'free', apiKey: 'key-ada' },
{ id: 'cust_2', name: 'Grace (pro)', tierId: 'pro', apiKey: 'key-grace' },
{ id: 'cust_3', name: 'Linus (business)', tierId: 'business', apiKey: 'key-linus' },
];
export interface AppDeps {
usage?: UsageRepo;
customers?: CustomerRepo;
pricingStore?: PricingStore;
invoices?: InvoiceRepo;
adminUsers?: AdminUserRepo;
sessions?: SessionRepo;
payments?: PaymentClient;
/** QR renderer for 2FA setup; defaults to the qrcode package. */
qr?: (uri: string) => Promise<string>;
}
const SPEC_PATH = path.join(PROJECT_ROOT, 'openapi.yaml');
const parseMetadata: RequestHandler = (req, res, next) => {
const raw = req.body?.metadata;
if (raw === undefined || raw === null || raw === '') {
res.locals.parsedMetadata = {};
return next();
}
if (typeof raw !== 'string') {
res.locals.parsedMetadata = raw;
return next();
}
try {
res.locals.parsedMetadata = JSON.parse(raw);
next();
} catch {
res.status(400).json({ error: 'invalid metadata JSON' });
}
};
export function buildApp(deps: AppDeps = {}): {
app: Express;
usage: UsageRepo;
customers: CustomerRepo;
pricing: PricingContext;
pricingStore: PricingStore;
invoices: InvoiceRepo;
items: StoredItem[];
} {
const customers = deps.customers ?? new InMemoryCustomerRepo(DEFAULT_CUSTOMERS);
const usage = deps.usage ?? new InMemoryUsageRepo();
const pricingStore = deps.pricingStore ?? new InMemoryPricingStore();
const invoices = deps.invoices ?? new InMemoryInvoiceRepo();
const adminUsers =
deps.adminUsers ?? InMemoryAdminUserRepo.seeded(seedAdminUsersFromEnv());
// Live pricing context: every quote reads the store, so admin edits apply immediately.
const pricing: PricingContext = {
get rateCard() {
return pricingStore.getRateCard();
},
get tiers() {
return new ConfigTierCatalog(pricingStore.getTiers());
},
};
const items: StoredItem[] = [];
const app = express();
app.use(express.json());
const spec = YAML.load(SPEC_PATH);
app.use('/docs', swaggerUi.serve, swaggerUi.setup(spec));
app.use(
'/admin/api',
adminLoginRouter(adminUsers),
adminAuth(),
adminRouter(pricingStore, customers, { usage, invoices, users: adminUsers }),
);
app.use('/admin', express.static(path.join(PROJECT_ROOT, 'admin')));
const sessions = deps.sessions ?? new InMemorySessionRepo();
const qr =
deps.qr ??
(async (uri: string) => {
const qrcode = await import('qrcode');
return qrcode.toDataURL(uri, { margin: 1, width: 220 });
});
const payments: PaymentClient = deps.payments ?? {
// Safe default for tests/dev: credits immediately, no Stripe call.
reload: async (_customer, amountCents) => ({ mode: 'dev', creditedCents: amountCents }),
};
app.use(
'/portal/api',
portalRouter({
customers,
sessions,
usage,
invoices,
tiers: () => pricingStore.getTiers(),
rateCard: () => pricingStore.getRateCard(),
payments,
qr,
}),
);
app.use('/portal', express.static(path.join(PROJECT_ROOT, 'portal')));
app.use('/v1', apiKeyAuth(customers));
app.use(
OpenApiValidator.middleware({
apiSpec: SPEC_PATH,
validateRequests: true,
validateResponses: false,
fileUploader: { limits: { fileSize: 25 * 1024 * 1024 } },
}),
);
app.get('/v1/status', meter('status', usage, pricing), (req, res) => {
res.json({ status: 'ok', quote: res.locals.quote });
});
app.post('/v1/transform', meter('transform', usage, pricing), (req, res) => {
const text = String(req.body?.text ?? '');
res.json({ output: text.toUpperCase(), quote: res.locals.quote });
});
app.post('/v1/storage', parseMetadata, meter('storage', usage, pricing), (req, res) => {
const metadata = res.locals.parsedMetadata as Record<string, unknown>;
const files = (req.files as Express.Multer.File[]) ?? [];
const item: StoredItem = {
id: randomUUID(),
customerId: req.customer!.id,
metadata,
attachments: files.map((f) => ({ filename: f.originalname, size: f.size })),
createdAt: new Date().toISOString(),
};
items.unshift(item);
res.json({ id: item.id, quote: res.locals.quote });
});
app.get('/v1/storage', meter('storage-list', usage, pricing), (req, res) => {
res.json({ items: items.filter((i) => i.customerId === req.customer!.id) });
});
app.get('/v1/usage', (req, res) => {
const since = new Date();
since.setUTCDate(1);
since.setUTCHours(0, 0, 0, 0);
const summary = usage.summaryFor(req.customer!.id, since);
const tier = pricing.tiers.find(req.customer!.tierId);
res.json(tier ? applyMonthlyCredit(summary, tier) : summary);
});
app.use((err: Error & { status?: number }, req: Request, res: Response, next: NextFunction) => {
res.status(err.status ?? 500).json({ error: err.message });
});
return { app, usage, customers, pricing, pricingStore, invoices, items };
}

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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