import { randomBytes } from 'crypto'; import { RequestHandler, Router } from 'express'; import { generateTotpSecret, hashPassword, SessionRepo, totpUri, verifyPassword, verifyTotp, } from './accounts'; import { renderInvoiceHtml } from './admin'; import { Customer, CustomerRepo } from './auth'; import { applyMonthlyCredit } from './billing/credit'; import { InvoiceRepo } from './invoicing'; import { RateCard, TierConfig } from './pricing'; import { UsageRepo } from './usage'; import { CreditLedger } from './credits'; import { composeStatement } from './statement'; import { BILLING_SUBJECTS, natsPublish, natsStatement, natsAdjust } from './billing-nats'; /** * 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; } 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; sessionTtlMs?: number; now?: () => number; credits?: CreditLedger; } const DEFAULT_SESSION_TTL_MS = 7 * 24 * 60 * 60 * 1000; const MIN_RELOAD_CENTS = 100; // $1 const MAX_RELOAD_CENTS = 1_000_000; // $10,000 /** Profile payload safe to return to the customer (no hashes/secrets). */ function publicProfile(c: Customer) { return { id: c.id, name: c.name, email: c.email, tierId: c.tierId, billingType: c.billingType ?? 'stripe', apiKey: c.apiKey, 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('/statement', async (req, res) => { const fromNats = await natsStatement(req.customer!.id, 'web'); if (fromNats) { res.json({ ...fromNats, source: 'nats' }); return; } res.json({ ...composeStatement({ customerId: req.customer!.id, prepaidCents: req.customer!.balanceCents ?? 0, credits: (deps.credits || new CreditLedger()).list(req.customer!.id), usage: deps.usage.listFor(req.customer!.id), invoices: deps.invoices.list({ customerId: req.customer!.id }), }), source: 'local', }); }); router.get('/invoices/:id', (req, res) => { const invoice = deps.invoices.get(req.params.id); if (!invoice || invoice.customerId !== req.customer!.id) { res.status(404).json({ error: 'invoice not found' }); return; } if (req.query.format === 'html') { res.type('html').send(renderInvoiceHtml(invoice, req.customer!.name)); return; } res.json(invoice); }); /* ---------------- 2FA ---------------- */ router.post('/2fa/setup', async (req, res) => { const secret = generateTotpSecret(); save(deps, { ...req.customer!, totpSecret: secret, totpEnabled: false }); const uri = totpUri(secret, req.customer!.email ?? req.customer!.id); res.json({ secret, uri, qr: await deps.qr(uri) }); }); router.post('/2fa/enable', (req, res) => { const { code } = req.body ?? {}; const secret = req.customer!.totpSecret; if (!secret || typeof code !== 'string' || !verifyTotp(secret, code, now())) { res.status(400).json({ error: 'invalid code — scan the QR and try the current 6-digit code' }); return; } save(deps, { ...req.customer!, totpEnabled: true }); res.json({ ok: true, totpEnabled: true }); }); router.post('/2fa/disable', (req, res) => { const { code } = req.body ?? {}; const secret = req.customer!.totpSecret; if (!secret || typeof code !== 'string' || !verifyTotp(secret, code, now())) { res.status(400).json({ error: 'invalid code' }); return; } save(deps, { ...req.customer!, totpSecret: undefined, totpEnabled: false }); res.json({ ok: true, totpEnabled: false }); }); /* ---------------- billing ---------------- */ router.post('/reload', async (req, res) => { const { amountCents } = req.body ?? {}; if ( typeof amountCents !== 'number' || !Number.isInteger(amountCents) || amountCents < MIN_RELOAD_CENTS || amountCents > MAX_RELOAD_CENTS ) { res .status(400) .json({ error: `amountCents must be an integer between ${MIN_RELOAD_CENTS} and ${MAX_RELOAD_CENTS}` }); return; } const result = await deps.payments.reload(req.customer!, amountCents); const customer = { ...req.customer!, balanceCents: (req.customer!.balanceCents ?? 0) + result.creditedCents, }; if (result.creditedCents > 0) save(deps, customer); if (result.creditedCents > 0) { natsPublish( BILLING_SUBJECTS.PAYMENT_RECORDED, { customerId: customer.id, cents: result.creditedCents, reason: 'reload', }, 'web', ); void natsAdjust( { customerId: customer.id, cents: result.creditedCents, reason: 'reload', agent: 'portal', kind: 'reload', }, 'web', ); } 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; }