Some checks are pending
offline / test (push) Waiting to run
Internal billing now uses verae.billing.* request-reply and pubs. zappier-account-balance tracks prepaid, credits, usage, and payments. Portal, admin, CS, and sales all review the same statement. Independent Forgejo repos stay split via push-module-repos.
320 lines
10 KiB
TypeScript
320 lines
10 KiB
TypeScript
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<PaymentResult>;
|
|
}
|
|
|
|
export interface PortalDeps {
|
|
customers: CustomerRepo;
|
|
sessions: SessionRepo;
|
|
usage: UsageRepo;
|
|
invoices: InvoiceRepo;
|
|
tiers: () => TierConfig[];
|
|
rateCard: () => RateCard;
|
|
payments: PaymentClient;
|
|
/** Renders an otpauth URI as a QR data URL; injectable for tests. */
|
|
qr: (uri: string) => Promise<string>;
|
|
sessionTtlMs?: number;
|
|
now?: () => number;
|
|
credits?: CreditLedger;
|
|
}
|
|
|
|
const DEFAULT_SESSION_TTL_MS = 7 * 24 * 60 * 60 * 1000;
|
|
const MIN_RELOAD_CENTS = 100; // $1
|
|
const MAX_RELOAD_CENTS = 1_000_000; // $10,000
|
|
|
|
/** Profile payload safe to return to the customer (no hashes/secrets). */
|
|
function publicProfile(c: Customer) {
|
|
return {
|
|
id: c.id,
|
|
name: c.name,
|
|
email: c.email,
|
|
tierId: c.tierId,
|
|
billingType: c.billingType ?? 'stripe',
|
|
apiKey: c.apiKey,
|
|
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);
|
|
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',
|
|
});
|
|
void natsAdjust({
|
|
customerId: customer.id,
|
|
cents: result.creditedCents,
|
|
reason: 'reload',
|
|
agent: 'portal',
|
|
kind: 'payment',
|
|
});
|
|
}
|
|
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;
|
|
}
|