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:
commit
b4150c8250
1364 changed files with 6814366 additions and 0 deletions
165
packages/zappier/src/accounts.ts
Normal file
165
packages/zappier/src/accounts.ts
Normal file
|
|
@ -0,0 +1,165 @@
|
|||
import { createHmac, randomBytes, scryptSync, timingSafeEqual } from 'crypto';
|
||||
|
||||
/**
|
||||
* Customer-portal identity primitives: password hashing (scrypt), TOTP
|
||||
* two-factor secrets (RFC 6238), and portal sessions. Pure functions plus
|
||||
* repo interfaces; SQLite adapters live in src/db/.
|
||||
*/
|
||||
|
||||
/* ---------------- password hashing (scrypt) ---------------- */
|
||||
|
||||
const SCRYPT_N = 16384;
|
||||
const SCRYPT_R = 8;
|
||||
const SCRYPT_P = 1;
|
||||
const KEY_LEN = 32;
|
||||
|
||||
/** Format: scrypt:N:r:p:<salt b64>:<hash b64> */
|
||||
export function hashPassword(password: string): string {
|
||||
const salt = randomBytes(16);
|
||||
const hash = scryptSync(password, salt, KEY_LEN, {
|
||||
N: SCRYPT_N,
|
||||
r: SCRYPT_R,
|
||||
p: SCRYPT_P,
|
||||
});
|
||||
return `scrypt:${SCRYPT_N}:${SCRYPT_R}:${SCRYPT_P}:${salt.toString('base64')}:${hash.toString('base64')}`;
|
||||
}
|
||||
|
||||
export function verifyPassword(password: string, stored: string): boolean {
|
||||
const parts = stored.split(':');
|
||||
if (parts.length !== 6 || parts[0] !== 'scrypt') return false;
|
||||
const [, n, r, p, saltB64, hashB64] = parts;
|
||||
const expected = Buffer.from(hashB64, 'base64');
|
||||
if (expected.length === 0) return false;
|
||||
const actual = scryptSync(password, Buffer.from(saltB64, 'base64'), expected.length, {
|
||||
N: Number(n),
|
||||
r: Number(r),
|
||||
p: Number(p),
|
||||
});
|
||||
return timingSafeEqual(actual, expected);
|
||||
}
|
||||
|
||||
/* ---------------- base32 (RFC 4648, no padding) ---------------- */
|
||||
|
||||
const B32_ALPHABET = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ234567';
|
||||
|
||||
export function base32Encode(buf: Buffer): string {
|
||||
let bits = 0;
|
||||
let value = 0;
|
||||
let out = '';
|
||||
for (const byte of buf) {
|
||||
value = (value << 8) | byte;
|
||||
bits += 8;
|
||||
while (bits >= 5) {
|
||||
out += B32_ALPHABET[(value >>> (bits - 5)) & 31];
|
||||
bits -= 5;
|
||||
}
|
||||
}
|
||||
if (bits > 0) out += B32_ALPHABET[(value << (5 - bits)) & 31];
|
||||
return out;
|
||||
}
|
||||
|
||||
export function base32Decode(s: string): Buffer {
|
||||
let bits = 0;
|
||||
let value = 0;
|
||||
const out: number[] = [];
|
||||
for (const ch of s.toUpperCase().replace(/=+$/, '')) {
|
||||
const idx = B32_ALPHABET.indexOf(ch);
|
||||
if (idx < 0) throw new Error(`invalid base32 character: ${ch}`);
|
||||
value = (value << 5) | idx;
|
||||
bits += 5;
|
||||
if (bits >= 8) {
|
||||
out.push((value >>> (bits - 8)) & 0xff);
|
||||
bits -= 8;
|
||||
}
|
||||
}
|
||||
return Buffer.from(out);
|
||||
}
|
||||
|
||||
/* ---------------- TOTP (RFC 6238, HMAC-SHA1, 30 s step, 6 digits) ---------------- */
|
||||
|
||||
export function hotp(secret: string, counter: number, digits = 6): string {
|
||||
const key = base32Decode(secret);
|
||||
const msg = Buffer.alloc(8);
|
||||
msg.writeBigUInt64BE(BigInt(counter));
|
||||
const h = createHmac('sha1', key).update(msg).digest();
|
||||
const offset = h[h.length - 1] & 0x0f;
|
||||
const code =
|
||||
(((h[offset] & 0x7f) << 24) |
|
||||
(h[offset + 1] << 16) |
|
||||
(h[offset + 2] << 8) |
|
||||
h[offset + 3]) %
|
||||
10 ** digits;
|
||||
return String(code).padStart(digits, '0');
|
||||
}
|
||||
|
||||
export function totp(secret: string, atMs: number, stepSec = 30, digits = 6): string {
|
||||
return hotp(secret, Math.floor(atMs / 1000 / stepSec), digits);
|
||||
}
|
||||
|
||||
export function verifyTotp(
|
||||
secret: string,
|
||||
code: string,
|
||||
atMs: number,
|
||||
window = 1,
|
||||
): boolean {
|
||||
if (!/^\d{6}$/.test(code)) return false;
|
||||
for (let w = -window; w <= window; w++) {
|
||||
if (totp(secret, atMs + w * 30_000) === code) return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
/** 160-bit secret, base32 without padding (authenticator-app standard). */
|
||||
export function generateTotpSecret(): string {
|
||||
return base32Encode(randomBytes(20));
|
||||
}
|
||||
|
||||
export function totpUri(secret: string, email: string, issuer = 'Zappier'): string {
|
||||
return `otpauth://totp/${encodeURIComponent(issuer)}:${encodeURIComponent(email)}?secret=${secret}&issuer=${encodeURIComponent(issuer)}`;
|
||||
}
|
||||
|
||||
/* ---------------- portal sessions ---------------- */
|
||||
|
||||
export interface PortalSession {
|
||||
token: string;
|
||||
customerId: string;
|
||||
createdMs: number;
|
||||
expiresMs: number;
|
||||
}
|
||||
|
||||
export interface SessionRepo {
|
||||
create(customerId: string, ttlMs: number): PortalSession;
|
||||
/** Returns the session, or undefined when unknown or expired at nowMs. */
|
||||
get(token: string, nowMs?: number): PortalSession | undefined;
|
||||
delete(token: string): void;
|
||||
}
|
||||
|
||||
export function newSessionToken(): string {
|
||||
return randomBytes(24).toString('hex');
|
||||
}
|
||||
|
||||
export class InMemorySessionRepo implements SessionRepo {
|
||||
private sessions = new Map<string, PortalSession>();
|
||||
|
||||
create(customerId: string, ttlMs: number): PortalSession {
|
||||
const now = Date.now();
|
||||
const session: PortalSession = {
|
||||
token: newSessionToken(),
|
||||
customerId,
|
||||
createdMs: now,
|
||||
expiresMs: now + ttlMs,
|
||||
};
|
||||
this.sessions.set(session.token, session);
|
||||
return session;
|
||||
}
|
||||
|
||||
get(token: string, nowMs = Date.now()): PortalSession | undefined {
|
||||
const s = this.sessions.get(token);
|
||||
if (!s || s.expiresMs <= nowMs) return undefined;
|
||||
return s;
|
||||
}
|
||||
|
||||
delete(token: string): void {
|
||||
this.sessions.delete(token);
|
||||
}
|
||||
}
|
||||
75
packages/zappier/src/admin-users.ts
Normal file
75
packages/zappier/src/admin-users.ts
Normal 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);
|
||||
}
|
||||
}
|
||||
485
packages/zappier/src/admin.ts
Normal file
485
packages/zappier/src/admin.ts
Normal 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, '&')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>')
|
||||
.replace(/"/g, '"');
|
||||
}
|
||||
|
||||
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
192
packages/zappier/src/app.ts
Normal 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 };
|
||||
}
|
||||
83
packages/zappier/src/auth.ts
Normal file
83
packages/zappier/src/auth.ts
Normal 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();
|
||||
};
|
||||
}
|
||||
19
packages/zappier/src/billing/credit.ts
Normal file
19
packages/zappier/src/billing/credit.ts
Normal file
|
|
@ -0,0 +1,19 @@
|
|||
import { TierConfig } from '../pricing';
|
||||
import { UsageSummary } from '../usage';
|
||||
|
||||
export interface BilledSummary extends UsageSummary {
|
||||
includedCents: number;
|
||||
billableCents: number;
|
||||
}
|
||||
|
||||
export function applyMonthlyCredit(
|
||||
summary: UsageSummary,
|
||||
tier: TierConfig,
|
||||
): BilledSummary {
|
||||
const includedCents = Math.min(summary.totalCents, tier.monthlyCreditCents);
|
||||
return {
|
||||
...summary,
|
||||
includedCents,
|
||||
billableCents: summary.totalCents - includedCents,
|
||||
};
|
||||
}
|
||||
31
packages/zappier/src/billing/reload.ts
Normal file
31
packages/zappier/src/billing/reload.ts
Normal file
|
|
@ -0,0 +1,31 @@
|
|||
import Stripe from 'stripe';
|
||||
import { PaymentClient } from '../portal';
|
||||
|
||||
/**
|
||||
* Stripe-backed reloads: creates a PaymentIntent and returns its client
|
||||
* secret. The balance is credited only after the payment confirms (webhook
|
||||
* step); until then creditedCents is 0 and the intent is pending.
|
||||
*/
|
||||
export function stripePaymentClient(secretKey: string): PaymentClient {
|
||||
const stripe = new Stripe(secretKey);
|
||||
return {
|
||||
reload: async (customer, amountCents) => {
|
||||
const intent = await stripe.paymentIntents.create({
|
||||
amount: amountCents,
|
||||
currency: 'usd',
|
||||
automatic_payment_methods: { enabled: true },
|
||||
metadata: { customerId: customer.id },
|
||||
});
|
||||
return {
|
||||
mode: 'stripe',
|
||||
creditedCents: 0,
|
||||
clientSecret: intent.client_secret ?? undefined,
|
||||
};
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/** True when the key looks usable (not empty / not the setup placeholder). */
|
||||
export function hasRealStripeKey(key: string | undefined): key is string {
|
||||
return typeof key === 'string' && key.startsWith('sk_');
|
||||
}
|
||||
41
packages/zappier/src/billing/stripe.ts
Normal file
41
packages/zappier/src/billing/stripe.ts
Normal 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;
|
||||
}
|
||||
71
packages/zappier/src/db/admin-user-repo.ts
Normal file
71
packages/zappier/src/db/admin-user-repo.ts
Normal file
|
|
@ -0,0 +1,71 @@
|
|||
import Database from 'better-sqlite3';
|
||||
import { AdminUser, AdminUserRepo, makeAdminUser } from '../admin-users';
|
||||
|
||||
/** SQLite-backed admin accounts; seeds from env only when the table is empty. */
|
||||
export class SqliteAdminUserRepo implements AdminUserRepo {
|
||||
private constructor(private db: Database.Database) {
|
||||
this.db.exec(`
|
||||
CREATE TABLE IF NOT EXISTS admin_users (
|
||||
id TEXT PRIMARY KEY,
|
||||
username TEXT NOT NULL UNIQUE,
|
||||
password_hash TEXT NOT NULL,
|
||||
active INTEGER NOT NULL DEFAULT 1,
|
||||
created_ms INTEGER NOT NULL
|
||||
)
|
||||
`);
|
||||
}
|
||||
|
||||
static seeded(
|
||||
db: Database.Database,
|
||||
seed: { username: string; password: string }[],
|
||||
): SqliteAdminUserRepo {
|
||||
const repo = new SqliteAdminUserRepo(db);
|
||||
const { n } = db.prepare('SELECT COUNT(*) AS n FROM admin_users').get() as { n: number };
|
||||
if (n === 0) {
|
||||
for (const s of seed) repo.save(makeAdminUser(s.username, s.password));
|
||||
}
|
||||
return repo;
|
||||
}
|
||||
|
||||
list(): AdminUser[] {
|
||||
const rows = this.db
|
||||
.prepare('SELECT * FROM admin_users ORDER BY username')
|
||||
.all() as Record<string, unknown>[];
|
||||
return rows.map(toAdminUser);
|
||||
}
|
||||
|
||||
findByUsername(username: string): AdminUser | undefined {
|
||||
const r = this.db.prepare('SELECT * FROM admin_users WHERE username = ?').get(username) as
|
||||
| Record<string, unknown>
|
||||
| undefined;
|
||||
return r ? toAdminUser(r) : undefined;
|
||||
}
|
||||
|
||||
save(user: AdminUser): void {
|
||||
this.db
|
||||
.prepare(
|
||||
`INSERT INTO admin_users (id, username, password_hash, active, created_ms)
|
||||
VALUES (@id, @username, @passwordHash, @active, @createdMs)
|
||||
ON CONFLICT(username) DO UPDATE SET
|
||||
password_hash = @passwordHash,
|
||||
active = @active`,
|
||||
)
|
||||
.run({
|
||||
id: user.id,
|
||||
username: user.username,
|
||||
passwordHash: user.passwordHash,
|
||||
active: user.active ? 1 : 0,
|
||||
createdMs: user.createdMs,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
function toAdminUser(r: Record<string, unknown>): AdminUser {
|
||||
return {
|
||||
id: r.id as string,
|
||||
username: r.username as string,
|
||||
passwordHash: r.password_hash as string,
|
||||
active: (r.active as number) === 1,
|
||||
createdMs: r.created_ms as number,
|
||||
};
|
||||
}
|
||||
68
packages/zappier/src/db/billing-repo.ts
Normal file
68
packages/zappier/src/db/billing-repo.ts
Normal file
|
|
@ -0,0 +1,68 @@
|
|||
import Database from 'better-sqlite3';
|
||||
|
||||
export interface BillingReportRepo {
|
||||
getReportedCents(customerId: string, period: string): number;
|
||||
upsertReportedCents(customerId: string, period: string, cumulativeCents: number): void;
|
||||
}
|
||||
|
||||
export interface JobLockRepo {
|
||||
tryAcquireLock(name: string, ttlMs: number): boolean;
|
||||
releaseLock(name: string): void;
|
||||
}
|
||||
|
||||
export class SqliteBillingReportRepo implements BillingReportRepo, JobLockRepo {
|
||||
constructor(
|
||||
private db: Database.Database,
|
||||
private now: () => number = Date.now,
|
||||
) {
|
||||
this.db.exec(`
|
||||
CREATE TABLE IF NOT EXISTS billing_reports (
|
||||
customer_id TEXT NOT NULL,
|
||||
period TEXT NOT NULL,
|
||||
reported_cents INTEGER NOT NULL,
|
||||
reported_at_ms INTEGER NOT NULL,
|
||||
PRIMARY KEY (customer_id, period)
|
||||
)
|
||||
`);
|
||||
this.db.exec(`
|
||||
CREATE TABLE IF NOT EXISTS job_locks (
|
||||
name TEXT PRIMARY KEY,
|
||||
acquired_at_ms INTEGER NOT NULL
|
||||
)
|
||||
`);
|
||||
}
|
||||
|
||||
getReportedCents(customerId: string, period: string): number {
|
||||
const row = this.db
|
||||
.prepare('SELECT reported_cents FROM billing_reports WHERE customer_id = ? AND period = ?')
|
||||
.get(customerId, period) as { reported_cents: number } | undefined;
|
||||
return row?.reported_cents ?? 0;
|
||||
}
|
||||
|
||||
upsertReportedCents(customerId: string, period: string, cumulativeCents: number): void {
|
||||
this.db
|
||||
.prepare(
|
||||
`INSERT INTO billing_reports (customer_id, period, reported_cents, reported_at_ms)
|
||||
VALUES (?, ?, ?, ?)
|
||||
ON CONFLICT(customer_id, period) DO UPDATE SET
|
||||
reported_cents = excluded.reported_cents,
|
||||
reported_at_ms = excluded.reported_at_ms`,
|
||||
)
|
||||
.run(customerId, period, cumulativeCents, this.now());
|
||||
}
|
||||
|
||||
tryAcquireLock(name: string, ttlMs: number): boolean {
|
||||
const result = this.db
|
||||
.prepare(
|
||||
`INSERT INTO job_locks (name, acquired_at_ms) VALUES (?, ?)
|
||||
ON CONFLICT(name) DO UPDATE SET acquired_at_ms = excluded.acquired_at_ms
|
||||
WHERE job_locks.acquired_at_ms <= excluded.acquired_at_ms - ?`,
|
||||
)
|
||||
.run(name, this.now(), ttlMs);
|
||||
return result.changes === 1;
|
||||
}
|
||||
|
||||
releaseLock(name: string): void {
|
||||
this.db.prepare('DELETE FROM job_locks WHERE name = ?').run(name);
|
||||
}
|
||||
}
|
||||
112
packages/zappier/src/db/customer-repo.ts
Normal file
112
packages/zappier/src/db/customer-repo.ts
Normal file
|
|
@ -0,0 +1,112 @@
|
|||
import Database from 'better-sqlite3';
|
||||
import { Customer, CustomerRepo } from '../auth';
|
||||
|
||||
export class SqliteCustomerRepo implements CustomerRepo {
|
||||
constructor(private db: Database.Database, seed: Customer[] = []) {
|
||||
this.db.exec(`
|
||||
CREATE TABLE IF NOT EXISTS customers (
|
||||
id TEXT PRIMARY KEY,
|
||||
name TEXT NOT NULL,
|
||||
tier_id TEXT NOT NULL,
|
||||
api_key TEXT NOT NULL UNIQUE,
|
||||
stripe_customer_id TEXT,
|
||||
multiplier_override REAL
|
||||
)
|
||||
`);
|
||||
// Idempotent migrations for pre-existing databases.
|
||||
this.ensureColumn('billing_type', `billing_type TEXT NOT NULL DEFAULT 'stripe'`);
|
||||
this.ensureColumn('email', 'email TEXT');
|
||||
this.ensureColumn('password_hash', 'password_hash TEXT');
|
||||
this.ensureColumn('totp_secret', 'totp_secret TEXT');
|
||||
this.ensureColumn('totp_enabled', 'totp_enabled INTEGER NOT NULL DEFAULT 0');
|
||||
this.ensureColumn('balance_cents', 'balance_cents INTEGER NOT NULL DEFAULT 0');
|
||||
this.ensureColumn('email_invoicing', 'email_invoicing INTEGER NOT NULL DEFAULT 0');
|
||||
const { n } = this.db.prepare('SELECT COUNT(*) AS n FROM customers').get() as { n: number };
|
||||
if (n === 0) {
|
||||
for (const c of seed) this.save(c);
|
||||
}
|
||||
}
|
||||
|
||||
private ensureColumn(name: string, ddl: string): void {
|
||||
const cols = this.db.prepare('PRAGMA table_info(customers)').all() as { name: string }[];
|
||||
if (!cols.some((c) => c.name === name)) {
|
||||
this.db.exec(`ALTER TABLE customers ADD COLUMN ${ddl}`);
|
||||
}
|
||||
}
|
||||
|
||||
findByApiKey(apiKey: string): Customer | undefined {
|
||||
const r = this.db.prepare('SELECT * FROM customers WHERE api_key = ?').get(apiKey) as
|
||||
| Record<string, unknown>
|
||||
| undefined;
|
||||
return r ? toCustomer(r) : undefined;
|
||||
}
|
||||
|
||||
findByEmail(email: string): Customer | undefined {
|
||||
const r = this.db
|
||||
.prepare('SELECT * FROM customers WHERE lower(email) = lower(?)')
|
||||
.get(email) as Record<string, unknown> | undefined;
|
||||
return r ? toCustomer(r) : undefined;
|
||||
}
|
||||
|
||||
list(): Customer[] {
|
||||
const rows = this.db.prepare('SELECT * FROM customers ORDER BY id').all() as Record<
|
||||
string,
|
||||
unknown
|
||||
>[];
|
||||
return rows.map(toCustomer);
|
||||
}
|
||||
|
||||
save(customer: Customer): void {
|
||||
this.db
|
||||
.prepare(
|
||||
`INSERT INTO customers (id, name, tier_id, api_key, stripe_customer_id, multiplier_override, billing_type, email, password_hash, totp_secret, totp_enabled, balance_cents, email_invoicing)
|
||||
VALUES (@id, @name, @tierId, @apiKey, @stripeCustomerId, @multiplierOverride, @billingType, @email, @passwordHash, @totpSecret, @totpEnabled, @balanceCents, @emailInvoicing)
|
||||
ON CONFLICT(id) DO UPDATE SET
|
||||
name = @name,
|
||||
tier_id = @tierId,
|
||||
api_key = @apiKey,
|
||||
stripe_customer_id = @stripeCustomerId,
|
||||
multiplier_override = @multiplierOverride,
|
||||
billing_type = @billingType,
|
||||
email = @email,
|
||||
password_hash = @passwordHash,
|
||||
totp_secret = @totpSecret,
|
||||
totp_enabled = @totpEnabled,
|
||||
balance_cents = @balanceCents,
|
||||
email_invoicing = @emailInvoicing`,
|
||||
)
|
||||
.run({
|
||||
id: customer.id,
|
||||
name: customer.name,
|
||||
tierId: customer.tierId,
|
||||
apiKey: customer.apiKey,
|
||||
stripeCustomerId: customer.stripeCustomerId ?? null,
|
||||
multiplierOverride: customer.multiplierOverride ?? null,
|
||||
billingType: customer.billingType ?? 'stripe',
|
||||
email: customer.email ?? null,
|
||||
passwordHash: customer.passwordHash ?? null,
|
||||
totpSecret: customer.totpSecret ?? null,
|
||||
totpEnabled: customer.totpEnabled ? 1 : 0,
|
||||
balanceCents: customer.balanceCents ?? 0,
|
||||
emailInvoicing: customer.emailInvoicing ? 1 : 0,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
function toCustomer(r: Record<string, unknown>): Customer {
|
||||
return {
|
||||
id: r.id as string,
|
||||
name: r.name as string,
|
||||
tierId: r.tier_id as string,
|
||||
apiKey: r.api_key as string,
|
||||
stripeCustomerId: (r.stripe_customer_id as string | null) ?? undefined,
|
||||
multiplierOverride: (r.multiplier_override as number | null) ?? undefined,
|
||||
billingType: ((r.billing_type as string | null) ?? 'stripe') as Customer['billingType'],
|
||||
email: (r.email as string | null) ?? undefined,
|
||||
passwordHash: (r.password_hash as string | null) ?? undefined,
|
||||
totpSecret: (r.totp_secret as string | null) ?? undefined,
|
||||
totpEnabled: ((r.totp_enabled as number | null) ?? 0) === 1,
|
||||
balanceCents: (r.balance_cents as number | null) ?? 0,
|
||||
emailInvoicing: ((r.email_invoicing as number | null) ?? 0) === 1,
|
||||
};
|
||||
}
|
||||
119
packages/zappier/src/db/invoice-repo.ts
Normal file
119
packages/zappier/src/db/invoice-repo.ts
Normal file
|
|
@ -0,0 +1,119 @@
|
|||
import Database from 'better-sqlite3';
|
||||
import { Invoice, InvoiceLine, InvoiceRepo } from '../invoicing';
|
||||
|
||||
export class SqliteInvoiceRepo implements InvoiceRepo {
|
||||
constructor(private db: Database.Database) {
|
||||
this.db.exec(`
|
||||
CREATE TABLE IF NOT EXISTS invoices (
|
||||
id TEXT PRIMARY KEY,
|
||||
customer_id TEXT NOT NULL,
|
||||
period TEXT NOT NULL,
|
||||
status TEXT NOT NULL,
|
||||
total_cents INTEGER NOT NULL,
|
||||
credit_cents INTEGER NOT NULL,
|
||||
billable_cents INTEGER NOT NULL,
|
||||
billing_type TEXT NOT NULL DEFAULT 'stripe',
|
||||
po_number TEXT,
|
||||
issued_at_ms INTEGER,
|
||||
due_at_ms INTEGER,
|
||||
paid_at_ms INTEGER
|
||||
);
|
||||
CREATE TABLE IF NOT EXISTS invoice_lines (
|
||||
invoice_id TEXT NOT NULL,
|
||||
endpoint_id TEXT NOT NULL,
|
||||
calls INTEGER NOT NULL,
|
||||
cents INTEGER NOT NULL,
|
||||
PRIMARY KEY (invoice_id, endpoint_id)
|
||||
);
|
||||
`);
|
||||
}
|
||||
|
||||
save(invoice: Invoice): void {
|
||||
this.db
|
||||
.prepare(
|
||||
`INSERT INTO invoices
|
||||
(id, customer_id, period, status, total_cents, credit_cents, billable_cents,
|
||||
billing_type, po_number, issued_at_ms, due_at_ms, paid_at_ms)
|
||||
VALUES (@id, @customerId, @period, @status, @totalCents, @creditCents, @billableCents,
|
||||
@billingType, @poNumber, @issuedAtMs, @dueAtMs, @paidAtMs)
|
||||
ON CONFLICT(id) DO UPDATE SET
|
||||
customer_id = @customerId, period = @period, status = @status,
|
||||
total_cents = @totalCents, credit_cents = @creditCents,
|
||||
billable_cents = @billableCents, billing_type = @billingType,
|
||||
po_number = @poNumber, issued_at_ms = @issuedAtMs,
|
||||
due_at_ms = @dueAtMs, paid_at_ms = @paidAtMs`,
|
||||
)
|
||||
.run({
|
||||
...invoice,
|
||||
poNumber: invoice.poNumber ?? null,
|
||||
issuedAtMs: invoice.issuedAtMs ?? null,
|
||||
dueAtMs: invoice.dueAtMs ?? null,
|
||||
paidAtMs: invoice.paidAtMs ?? null,
|
||||
});
|
||||
// Replace the line set atomically with the invoice row.
|
||||
this.db.prepare('DELETE FROM invoice_lines WHERE invoice_id = ?').run(invoice.id);
|
||||
const insert = this.db.prepare(
|
||||
'INSERT INTO invoice_lines (invoice_id, endpoint_id, calls, cents) VALUES (?, ?, ?, ?)',
|
||||
);
|
||||
for (const line of invoice.lines) {
|
||||
insert.run(invoice.id, line.endpointId, line.calls, line.cents);
|
||||
}
|
||||
}
|
||||
|
||||
get(id: string): Invoice | undefined {
|
||||
const row = this.db.prepare('SELECT * FROM invoices WHERE id = ?').get(id) as
|
||||
| Record<string, unknown>
|
||||
| undefined;
|
||||
return row ? this.toInvoice(row) : undefined;
|
||||
}
|
||||
|
||||
list(filter: { customerId?: string; period?: string; status?: Invoice['status'] }): Invoice[] {
|
||||
const where: string[] = [];
|
||||
const params: Record<string, string> = {};
|
||||
if (filter.customerId) {
|
||||
where.push('customer_id = @customerId');
|
||||
params.customerId = filter.customerId;
|
||||
}
|
||||
if (filter.period) {
|
||||
where.push('period = @period');
|
||||
params.period = filter.period;
|
||||
}
|
||||
if (filter.status) {
|
||||
where.push('status = @status');
|
||||
params.status = filter.status;
|
||||
}
|
||||
const sql = `SELECT * FROM invoices ${where.length ? `WHERE ${where.join(' AND ')}` : ''} ORDER BY id`;
|
||||
const rows = this.db.prepare(sql).all(params) as Record<string, unknown>[];
|
||||
return rows.map((r) => this.toInvoice(r));
|
||||
}
|
||||
|
||||
nextSequence(period: string): number {
|
||||
const { n } = this.db
|
||||
.prepare('SELECT COUNT(*) AS n FROM invoices WHERE period = ?')
|
||||
.get(period) as { n: number };
|
||||
return n + 1;
|
||||
}
|
||||
|
||||
private toInvoice(row: Record<string, unknown>): Invoice {
|
||||
const lines = this.db
|
||||
.prepare('SELECT endpoint_id, calls, cents FROM invoice_lines WHERE invoice_id = ? ORDER BY endpoint_id')
|
||||
.all(row.id as string) as { endpoint_id: string; calls: number; cents: number }[];
|
||||
return {
|
||||
id: row.id as string,
|
||||
customerId: row.customer_id as string,
|
||||
period: row.period as string,
|
||||
status: row.status as Invoice['status'],
|
||||
lines: lines.map(
|
||||
(l): InvoiceLine => ({ endpointId: l.endpoint_id, calls: l.calls, cents: l.cents }),
|
||||
),
|
||||
totalCents: row.total_cents as number,
|
||||
creditCents: row.credit_cents as number,
|
||||
billableCents: row.billable_cents as number,
|
||||
billingType: row.billing_type as Invoice['billingType'],
|
||||
poNumber: (row.po_number as string | null) ?? undefined,
|
||||
issuedAtMs: (row.issued_at_ms as number | null) ?? undefined,
|
||||
dueAtMs: (row.due_at_ms as number | null) ?? undefined,
|
||||
paidAtMs: (row.paid_at_ms as number | null) ?? undefined,
|
||||
};
|
||||
}
|
||||
}
|
||||
106
packages/zappier/src/db/pricing-store.ts
Normal file
106
packages/zappier/src/db/pricing-store.ts
Normal file
|
|
@ -0,0 +1,106 @@
|
|||
import Database from 'better-sqlite3';
|
||||
import {
|
||||
DEFAULT_RATE_CARD,
|
||||
DEFAULT_TIERS,
|
||||
PriceRule,
|
||||
PricingStore,
|
||||
RateCard,
|
||||
TierConfig,
|
||||
} from '../pricing';
|
||||
|
||||
export class SqlitePricingStore implements PricingStore {
|
||||
constructor(
|
||||
private db: Database.Database,
|
||||
seedCard: RateCard = DEFAULT_RATE_CARD,
|
||||
seedTiers: TierConfig[] = DEFAULT_TIERS,
|
||||
) {
|
||||
this.db.exec(`
|
||||
CREATE TABLE IF NOT EXISTS price_endpoints (
|
||||
endpoint_id TEXT PRIMARY KEY,
|
||||
rule_json TEXT NOT NULL
|
||||
);
|
||||
CREATE TABLE IF NOT EXISTS tiers (
|
||||
id TEXT PRIMARY KEY,
|
||||
name TEXT NOT NULL,
|
||||
multiplier REAL NOT NULL,
|
||||
monthly_credit_cents INTEGER NOT NULL,
|
||||
default_rule_json TEXT
|
||||
)
|
||||
`);
|
||||
const { n: endpointCount } = this.db
|
||||
.prepare('SELECT COUNT(*) AS n FROM price_endpoints')
|
||||
.get() as { n: number };
|
||||
if (endpointCount === 0) {
|
||||
for (const [id, rule] of Object.entries(seedCard.endpoints)) {
|
||||
this.upsertEndpoint(id, rule);
|
||||
}
|
||||
}
|
||||
const { n: tierCount } = this.db.prepare('SELECT COUNT(*) AS n FROM tiers').get() as {
|
||||
n: number;
|
||||
};
|
||||
if (tierCount === 0) {
|
||||
for (const tier of seedTiers) {
|
||||
this.upsertTier(tier);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
getRateCard(): RateCard {
|
||||
const rows = this.db.prepare('SELECT * FROM price_endpoints').all() as Record<string, unknown>[];
|
||||
const endpoints: Record<string, PriceRule> = {};
|
||||
for (const r of rows) {
|
||||
endpoints[r.endpoint_id as string] = JSON.parse(r.rule_json as string) as PriceRule;
|
||||
}
|
||||
return { endpoints };
|
||||
}
|
||||
|
||||
getTiers(): TierConfig[] {
|
||||
const rows = this.db.prepare('SELECT * FROM tiers ORDER BY rowid').all() as Record<string, unknown>[];
|
||||
return rows.map((r) => ({
|
||||
id: r.id as string,
|
||||
name: r.name as string,
|
||||
multiplier: r.multiplier as number,
|
||||
monthlyCreditCents: r.monthly_credit_cents as number,
|
||||
defaultRule: r.default_rule_json
|
||||
? (JSON.parse(r.default_rule_json as string) as PriceRule)
|
||||
: undefined,
|
||||
}));
|
||||
}
|
||||
|
||||
upsertEndpoint(endpointId: string, rule: PriceRule): void {
|
||||
this.db
|
||||
.prepare(
|
||||
`INSERT INTO price_endpoints (endpoint_id, rule_json) VALUES (?, ?)
|
||||
ON CONFLICT(endpoint_id) DO UPDATE SET rule_json = excluded.rule_json`,
|
||||
)
|
||||
.run(endpointId, JSON.stringify(rule));
|
||||
}
|
||||
|
||||
deleteEndpoint(endpointId: string): void {
|
||||
this.db.prepare('DELETE FROM price_endpoints WHERE endpoint_id = ?').run(endpointId);
|
||||
}
|
||||
|
||||
upsertTier(tier: TierConfig): void {
|
||||
this.db
|
||||
.prepare(
|
||||
`INSERT INTO tiers (id, name, multiplier, monthly_credit_cents, default_rule_json)
|
||||
VALUES (@id, @name, @multiplier, @monthlyCreditCents, @defaultRuleJson)
|
||||
ON CONFLICT(id) DO UPDATE SET
|
||||
name = @name,
|
||||
multiplier = @multiplier,
|
||||
monthly_credit_cents = @monthlyCreditCents,
|
||||
default_rule_json = @defaultRuleJson`,
|
||||
)
|
||||
.run({
|
||||
id: tier.id,
|
||||
name: tier.name,
|
||||
multiplier: tier.multiplier,
|
||||
monthlyCreditCents: tier.monthlyCreditCents,
|
||||
defaultRuleJson: tier.defaultRule ? JSON.stringify(tier.defaultRule) : null,
|
||||
});
|
||||
}
|
||||
|
||||
deleteTier(tierId: string): void {
|
||||
this.db.prepare('DELETE FROM tiers WHERE id = ?').run(tierId);
|
||||
}
|
||||
}
|
||||
51
packages/zappier/src/db/session-repo.ts
Normal file
51
packages/zappier/src/db/session-repo.ts
Normal file
|
|
@ -0,0 +1,51 @@
|
|||
import Database from 'better-sqlite3';
|
||||
import { newSessionToken, PortalSession, SessionRepo } from '../accounts';
|
||||
|
||||
/** SQLite-backed portal sessions; survives server restarts. */
|
||||
export class SqliteSessionRepo implements SessionRepo {
|
||||
constructor(private db: Database.Database) {
|
||||
this.db.exec(`
|
||||
CREATE TABLE IF NOT EXISTS portal_sessions (
|
||||
token TEXT PRIMARY KEY,
|
||||
customer_id TEXT NOT NULL,
|
||||
created_ms INTEGER NOT NULL,
|
||||
expires_ms INTEGER NOT NULL
|
||||
)
|
||||
`);
|
||||
}
|
||||
|
||||
create(customerId: string, ttlMs: number): PortalSession {
|
||||
const now = Date.now();
|
||||
const session: PortalSession = {
|
||||
token: newSessionToken(),
|
||||
customerId,
|
||||
createdMs: now,
|
||||
expiresMs: now + ttlMs,
|
||||
};
|
||||
this.db
|
||||
.prepare(
|
||||
'INSERT INTO portal_sessions (token, customer_id, created_ms, expires_ms) VALUES (?, ?, ?, ?)',
|
||||
)
|
||||
.run(session.token, session.customerId, session.createdMs, session.expiresMs);
|
||||
return session;
|
||||
}
|
||||
|
||||
get(token: string, nowMs = Date.now()): PortalSession | undefined {
|
||||
const r = this.db
|
||||
.prepare('SELECT * FROM portal_sessions WHERE token = ?')
|
||||
.get(token) as
|
||||
| { token: string; customer_id: string; created_ms: number; expires_ms: number }
|
||||
| undefined;
|
||||
if (!r || r.expires_ms <= nowMs) return undefined;
|
||||
return {
|
||||
token: r.token,
|
||||
customerId: r.customer_id,
|
||||
createdMs: r.created_ms,
|
||||
expiresMs: r.expires_ms,
|
||||
};
|
||||
}
|
||||
|
||||
delete(token: string): void {
|
||||
this.db.prepare('DELETE FROM portal_sessions WHERE token = ?').run(token);
|
||||
}
|
||||
}
|
||||
61
packages/zappier/src/db/usage-repo.ts
Normal file
61
packages/zappier/src/db/usage-repo.ts
Normal 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));
|
||||
}
|
||||
}
|
||||
41
packages/zappier/src/index.ts
Normal file
41
packages/zappier/src/index.ts
Normal 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`);
|
||||
});
|
||||
106
packages/zappier/src/invoicing.ts
Normal file
106
packages/zappier/src/invoicing.ts
Normal file
|
|
@ -0,0 +1,106 @@
|
|||
import { Customer } from './auth';
|
||||
import { TierConfig } from './pricing';
|
||||
import { UsageEntry } from './usage';
|
||||
|
||||
export interface InvoiceLine {
|
||||
endpointId: string;
|
||||
calls: number;
|
||||
cents: number;
|
||||
}
|
||||
|
||||
export interface Invoice {
|
||||
/** INV-<period>-<zero-padded sequence>, e.g. INV-2026-07-0007 */
|
||||
id: string;
|
||||
customerId: string;
|
||||
/** Billing period, YYYY-MM */
|
||||
period: string;
|
||||
status: 'draft' | 'issued' | 'paid';
|
||||
lines: InvoiceLine[];
|
||||
/** Gross usage in cents (sum of lines, before credit). */
|
||||
totalCents: number;
|
||||
/** Monthly credit actually consumed. */
|
||||
creditCents: number;
|
||||
/** totalCents − creditCents, floored at 0. */
|
||||
billableCents: number;
|
||||
billingType: 'stripe' | 'purchase_order';
|
||||
poNumber?: string;
|
||||
issuedAtMs?: number;
|
||||
dueAtMs?: number;
|
||||
paidAtMs?: number;
|
||||
}
|
||||
|
||||
export interface InvoiceRepo {
|
||||
save(invoice: Invoice): void;
|
||||
get(id: string): Invoice | undefined;
|
||||
list(filter: {
|
||||
customerId?: string;
|
||||
period?: string;
|
||||
status?: Invoice['status'];
|
||||
}): Invoice[];
|
||||
/** 1-based sequence for the next invoice id within a period. */
|
||||
nextSequence(period: string): number;
|
||||
}
|
||||
|
||||
/** Test/dev adapter; production uses SqliteInvoiceRepo. */
|
||||
export class InMemoryInvoiceRepo implements InvoiceRepo {
|
||||
private invoices = new Map<string, Invoice>();
|
||||
|
||||
save(invoice: Invoice): void {
|
||||
this.invoices.set(invoice.id, { ...invoice, lines: invoice.lines.map((l) => ({ ...l })) });
|
||||
}
|
||||
|
||||
get(id: string): Invoice | undefined {
|
||||
return this.invoices.get(id);
|
||||
}
|
||||
|
||||
list(filter: {
|
||||
customerId?: string;
|
||||
period?: string;
|
||||
status?: Invoice['status'];
|
||||
}): Invoice[] {
|
||||
return [...this.invoices.values()]
|
||||
.filter((i) => (filter.customerId ? i.customerId === filter.customerId : true))
|
||||
.filter((i) => (filter.period ? i.period === filter.period : true))
|
||||
.filter((i) => (filter.status ? i.status === filter.status : true))
|
||||
.sort((a, b) => a.id.localeCompare(b.id));
|
||||
}
|
||||
|
||||
nextSequence(period: string): number {
|
||||
return [...this.invoices.values()].filter((i) => i.period === period).length + 1;
|
||||
}
|
||||
}
|
||||
|
||||
export function buildInvoice(args: {
|
||||
customer: Customer;
|
||||
period: string;
|
||||
sequence: number;
|
||||
entries: UsageEntry[];
|
||||
tier: TierConfig;
|
||||
poNumber?: string;
|
||||
}): Invoice {
|
||||
const { customer, period, sequence, entries, tier, poNumber } = args;
|
||||
const byEndpoint = new Map<string, InvoiceLine>();
|
||||
for (const e of entries) {
|
||||
const line = byEndpoint.get(e.endpointId) ?? { endpointId: e.endpointId, calls: 0, cents: 0 };
|
||||
line.calls += 1;
|
||||
line.cents += e.cents;
|
||||
byEndpoint.set(e.endpointId, line);
|
||||
}
|
||||
const lines = [...byEndpoint.values()].sort((a, b) =>
|
||||
a.endpointId.localeCompare(b.endpointId),
|
||||
);
|
||||
const totalCents = lines.reduce((sum, l) => sum + l.cents, 0);
|
||||
const creditCents = Math.min(totalCents, tier.monthlyCreditCents);
|
||||
return {
|
||||
id: `INV-${period}-${String(sequence).padStart(4, '0')}`,
|
||||
customerId: customer.id,
|
||||
period,
|
||||
status: 'draft',
|
||||
lines,
|
||||
totalCents,
|
||||
creditCents,
|
||||
billableCents: totalCents - creditCents,
|
||||
billingType: customer.billingType ?? 'stripe',
|
||||
...(poNumber !== undefined ? { poNumber } : {}),
|
||||
};
|
||||
}
|
||||
129
packages/zappier/src/jobs/report-usage.ts
Normal file
129
packages/zappier/src/jobs/report-usage.ts
Normal 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);
|
||||
});
|
||||
}
|
||||
48
packages/zappier/src/meter.ts
Normal file
48
packages/zappier/src/meter.ts
Normal 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();
|
||||
};
|
||||
}
|
||||
9
packages/zappier/src/paths.ts
Normal file
9
packages/zappier/src/paths.ts
Normal 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, '..');
|
||||
284
packages/zappier/src/portal.ts
Normal file
284
packages/zappier/src/portal.ts
Normal 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;
|
||||
}
|
||||
169
packages/zappier/src/pricing.ts
Normal file
169
packages/zappier/src/pricing.ts
Normal 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,
|
||||
};
|
||||
}
|
||||
98
packages/zappier/src/reports.ts
Normal file
98
packages/zappier/src/reports.ts
Normal 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');
|
||||
}
|
||||
54
packages/zappier/src/usage.ts
Normal file
54
packages/zappier/src/usage.ts
Normal 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));
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue