billing
This commit is contained in:
49
backend/src/routes/billing.ts
Normal file
49
backend/src/routes/billing.ts
Normal file
@@ -0,0 +1,49 @@
|
||||
import { Router } from 'express';
|
||||
import { requireAuth, requireSuperAdmin } from '../middleware/auth.js';
|
||||
import { computeMonthlyBilling, listBillingEvents, PRICE_PER_INBOX } from '../services/billing.js';
|
||||
|
||||
export const billingRouter = Router();
|
||||
billingRouter.use(requireAuth);
|
||||
billingRouter.use(requireSuperAdmin);
|
||||
|
||||
/**
|
||||
* GET /api/billing/summary
|
||||
* Optional query params:
|
||||
* ?domain=foo.com -> restrict to one domain
|
||||
*
|
||||
* Returns:
|
||||
* { price_per_inbox: 5,
|
||||
* months: [
|
||||
* { domain, ym, year, month, inbox_count, amount_usd, inbox_emails: [...] },
|
||||
* ...
|
||||
* ]
|
||||
* }
|
||||
*/
|
||||
billingRouter.get('/summary', async (req, res) => {
|
||||
const domain = req.query.domain ? String(req.query.domain).toLowerCase() : undefined;
|
||||
const months = await computeMonthlyBilling({ domain });
|
||||
res.json({
|
||||
price_per_inbox: PRICE_PER_INBOX,
|
||||
months,
|
||||
});
|
||||
});
|
||||
|
||||
/**
|
||||
* GET /api/billing/events
|
||||
* Raw event log. Useful for "Show me the create/delete history".
|
||||
*
|
||||
* Optional query params:
|
||||
* ?domain=foo.com
|
||||
* ?from=2026-01-01T00:00:00Z
|
||||
* ?to=2026-02-01T00:00:00Z
|
||||
* ?limit=200 (1..5000)
|
||||
*/
|
||||
billingRouter.get('/events', async (req, res) => {
|
||||
const events = await listBillingEvents({
|
||||
domain: req.query.domain ? String(req.query.domain).toLowerCase() : undefined,
|
||||
fromIso: req.query.from ? String(req.query.from) : undefined,
|
||||
toIso: req.query.to ? String(req.query.to) : undefined,
|
||||
limit: req.query.limit ? Number(req.query.limit) : 500,
|
||||
});
|
||||
res.json(events);
|
||||
});
|
||||
@@ -7,6 +7,7 @@ import { DmsService } from '../services/dms.js';
|
||||
import { SyncService } from '../services/sync.js';
|
||||
import { DynamoRulesService } from '../services/dynamodb.js';
|
||||
import { audit } from '../services/audit.js';
|
||||
import { recordBillingEvent } from '../services/billing.js';
|
||||
import { domainFromEmail, localPartFromEmail, normalizeEmail } from '../utils/email.js';
|
||||
|
||||
export const mailboxesRouter = Router();
|
||||
@@ -102,6 +103,7 @@ mailboxesRouter.post('/', async (req, res) => {
|
||||
await sync.syncFromDms();
|
||||
await refreshQuotaForMailbox(email).catch((err) => console.warn(`Could not refresh quota for ${email}:`, err));
|
||||
await audit(req.user!.email, 'mailbox.create', 'mailbox', email, { domain }, req.ip);
|
||||
await recordBillingEvent('created', domain, email, req.user!.email);
|
||||
res.status(201).json({ email, domain, local_part: localPartFromEmail(email) });
|
||||
});
|
||||
|
||||
@@ -113,6 +115,7 @@ mailboxesRouter.delete('/:email', async (req, res) => {
|
||||
await dms.syncSesDomain(domain).catch(() => undefined);
|
||||
await pool.query(`UPDATE mailboxes SET status='deleted', deleted_at=now(), updated_at=now() WHERE email_address=$1`, [email]);
|
||||
await audit(req.user!.email, 'mailbox.delete', 'mailbox', email, { domain }, req.ip);
|
||||
await recordBillingEvent('deleted', domain, email, req.user!.email);
|
||||
res.json({ ok: true });
|
||||
});
|
||||
|
||||
@@ -125,8 +128,6 @@ mailboxesRouter.post('/:email/password', async (req, res) => {
|
||||
res.json({ ok: true });
|
||||
});
|
||||
|
||||
// Set storage quota for a single mailbox.
|
||||
// quota_gb is an integer between 1 and 1024 (GB).
|
||||
mailboxesRouter.post('/:email/quota', async (req, res) => {
|
||||
const body = z.object({ quota_gb: z.number().int().min(1).max(1024) }).parse(req.body);
|
||||
const email = normalizeEmail(req.params.email);
|
||||
|
||||
@@ -10,6 +10,7 @@ import { domainsRouter } from './routes/domains.js';
|
||||
import { mailboxesRouter } from './routes/mailboxes.js';
|
||||
import { auditRouter } from './routes/audit.js';
|
||||
import { adminsRouter } from './routes/admins.js';
|
||||
import { billingRouter } from './routes/billing.js';
|
||||
import { SyncService } from './services/sync.js';
|
||||
|
||||
const __dirname = dirname(fileURLToPath(import.meta.url));
|
||||
@@ -43,6 +44,7 @@ app.use('/api/domains', domainsRouter);
|
||||
app.use('/api/mailboxes', mailboxesRouter);
|
||||
app.use('/api/audit', auditRouter);
|
||||
app.use('/api/admins', adminsRouter);
|
||||
app.use('/api/billing', billingRouter);
|
||||
|
||||
app.use((err: any, req: express.Request, res: express.Response, _next: express.NextFunction) => {
|
||||
const status = err.status ?? err.statusCode ?? 500;
|
||||
@@ -61,7 +63,6 @@ const publicDir = config.publicDir.startsWith('/')
|
||||
|
||||
console.log(`Serving frontend from: ${publicDir}`);
|
||||
|
||||
// Avoid stale frontend JS while we are actively developing the MVP.
|
||||
app.use((req, res, next) => {
|
||||
if (
|
||||
req.path.endsWith('.js') ||
|
||||
|
||||
208
backend/src/services/billing.ts
Normal file
208
backend/src/services/billing.ts
Normal file
@@ -0,0 +1,208 @@
|
||||
import { pool } from '../db.js';
|
||||
|
||||
const PRICE_PER_INBOX_USD = 5;
|
||||
|
||||
export interface BillingEvent {
|
||||
id: number;
|
||||
occurred_at: string;
|
||||
domain: string;
|
||||
email: string;
|
||||
action: 'created' | 'deleted';
|
||||
actor_email: string | null;
|
||||
notes: string | null;
|
||||
}
|
||||
|
||||
export interface MonthlyDomainBilling {
|
||||
domain: string;
|
||||
year: number;
|
||||
month: number; // 1-12
|
||||
ym: string; // 'YYYY-MM'
|
||||
inbox_count: number;
|
||||
amount_usd: number;
|
||||
inbox_emails: string[];
|
||||
}
|
||||
|
||||
/**
|
||||
* Record a billing event. Called from the create/delete mailbox routes.
|
||||
* Failures are logged but never thrown — billing must not block the
|
||||
* actual mailbox operation that just succeeded.
|
||||
*/
|
||||
export async function recordBillingEvent(
|
||||
action: 'created' | 'deleted',
|
||||
domain: string,
|
||||
email: string,
|
||||
actorEmail: string | null,
|
||||
notes?: string,
|
||||
): Promise<void> {
|
||||
try {
|
||||
await pool.query(
|
||||
`INSERT INTO mailbox_billing_events (domain, email, action, actor_email, notes)
|
||||
VALUES ($1, $2, $3, $4, $5)`,
|
||||
[domain.toLowerCase(), email.toLowerCase(), action, actorEmail, notes ?? null],
|
||||
);
|
||||
} catch (err) {
|
||||
console.warn('[billing] failed to record event:', err);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get raw events for a domain (or all domains) within a time range.
|
||||
*/
|
||||
export async function listBillingEvents(opts: {
|
||||
domain?: string;
|
||||
fromIso?: string;
|
||||
toIso?: string;
|
||||
limit?: number;
|
||||
}): Promise<BillingEvent[]> {
|
||||
const params: unknown[] = [];
|
||||
const where: string[] = [];
|
||||
|
||||
if (opts.domain) {
|
||||
params.push(opts.domain.toLowerCase());
|
||||
where.push(`domain = $${params.length}`);
|
||||
}
|
||||
if (opts.fromIso) {
|
||||
params.push(opts.fromIso);
|
||||
where.push(`occurred_at >= $${params.length}`);
|
||||
}
|
||||
if (opts.toIso) {
|
||||
params.push(opts.toIso);
|
||||
where.push(`occurred_at < $${params.length}`);
|
||||
}
|
||||
|
||||
const limit = Math.min(Math.max(opts.limit ?? 500, 1), 5000);
|
||||
|
||||
const sql = `
|
||||
SELECT id, occurred_at, domain, email, action, actor_email, notes
|
||||
FROM mailbox_billing_events
|
||||
${where.length ? 'WHERE ' + where.join(' AND ') : ''}
|
||||
ORDER BY occurred_at DESC, id DESC
|
||||
LIMIT ${limit}
|
||||
`;
|
||||
|
||||
const result = await pool.query(sql, params);
|
||||
return result.rows;
|
||||
}
|
||||
|
||||
/**
|
||||
* Compute monthly billing aggregation across all months that contain
|
||||
* at least one mailbox in the given domain.
|
||||
*
|
||||
* Rule: a mailbox is billable in a month if it existed at any point
|
||||
* during that month — i.e. it was created on or before the last day
|
||||
* of the month, AND (still active OR was deleted on or after the
|
||||
* first day of that same month).
|
||||
*
|
||||
* In other words: every month between the create and the delete
|
||||
* (inclusive on both ends) counts. If still active, every month
|
||||
* since creation up to and including the current month counts.
|
||||
*/
|
||||
export async function computeMonthlyBilling(opts: {
|
||||
domain?: string;
|
||||
}): Promise<MonthlyDomainBilling[]> {
|
||||
// Pull events. We need the full history, but for performance we
|
||||
// could later add an "earliest month" cutoff. With <100k events
|
||||
// this is well under 100ms.
|
||||
const rows = await listBillingEvents({ domain: opts.domain, limit: 5000 });
|
||||
|
||||
// Group events per (domain, email) so we can pair create/delete
|
||||
// and reconstruct lifespans.
|
||||
const byMailbox = new Map<string, BillingEvent[]>();
|
||||
for (const ev of rows) {
|
||||
const key = `${ev.domain}\u0000${ev.email}`;
|
||||
if (!byMailbox.has(key)) byMailbox.set(key, []);
|
||||
byMailbox.get(key)!.push(ev);
|
||||
}
|
||||
|
||||
const now = new Date();
|
||||
|
||||
// For each domain+email, walk through events sorted ascending and
|
||||
// build a list of (createdAt, deletedAt|null) intervals. A mailbox
|
||||
// can be re-created after deletion, so multiple intervals per
|
||||
// (domain, email) are possible.
|
||||
type Interval = { domain: string; email: string; from: Date; to: Date | null };
|
||||
const intervals: Interval[] = [];
|
||||
|
||||
for (const [key, evs] of byMailbox) {
|
||||
const [domain, email] = key.split('\u0000');
|
||||
const sorted = [...evs].sort((a, b) =>
|
||||
new Date(a.occurred_at).getTime() - new Date(b.occurred_at).getTime()
|
||||
);
|
||||
|
||||
let openCreatedAt: Date | null = null;
|
||||
|
||||
for (const ev of sorted) {
|
||||
if (ev.action === 'created') {
|
||||
// If we already had an open interval (no matching delete), we
|
||||
// close it implicitly on the new create. This shouldn't happen
|
||||
// in practice but guards against malformed data.
|
||||
if (openCreatedAt) {
|
||||
intervals.push({ domain, email, from: openCreatedAt, to: new Date(ev.occurred_at) });
|
||||
}
|
||||
openCreatedAt = new Date(ev.occurred_at);
|
||||
} else if (ev.action === 'deleted' && openCreatedAt) {
|
||||
intervals.push({ domain, email, from: openCreatedAt, to: new Date(ev.occurred_at) });
|
||||
openCreatedAt = null;
|
||||
}
|
||||
// delete without prior create is ignored (defensive).
|
||||
}
|
||||
|
||||
if (openCreatedAt) {
|
||||
// Still active: open-ended interval up to "now".
|
||||
intervals.push({ domain, email, from: openCreatedAt, to: null });
|
||||
}
|
||||
}
|
||||
|
||||
// Now bucket each interval into the months it covers.
|
||||
// Per (domain, ym) we keep a Set of emails to dedupe and to expose
|
||||
// the actual list in the UI.
|
||||
type Bucket = { domain: string; ym: string; year: number; month: number; emails: Set<string> };
|
||||
const buckets = new Map<string, Bucket>();
|
||||
|
||||
const ensureBucket = (domain: string, year: number, month: number): Bucket => {
|
||||
const ym = `${year}-${String(month).padStart(2, '0')}`;
|
||||
const key = `${domain}\u0000${ym}`;
|
||||
let b = buckets.get(key);
|
||||
if (!b) {
|
||||
b = { domain, ym, year, month, emails: new Set() };
|
||||
buckets.set(key, b);
|
||||
}
|
||||
return b;
|
||||
};
|
||||
|
||||
for (const iv of intervals) {
|
||||
const fromY = iv.from.getUTCFullYear();
|
||||
const fromM = iv.from.getUTCMonth() + 1;
|
||||
const end = iv.to ?? now;
|
||||
const toY = end.getUTCFullYear();
|
||||
const toM = end.getUTCMonth() + 1;
|
||||
|
||||
let y = fromY;
|
||||
let m = fromM;
|
||||
while (y < toY || (y === toY && m <= toM)) {
|
||||
ensureBucket(iv.domain, y, m).emails.add(iv.email);
|
||||
m++;
|
||||
if (m > 12) { m = 1; y++; }
|
||||
}
|
||||
}
|
||||
|
||||
const result: MonthlyDomainBilling[] = [...buckets.values()].map((b) => ({
|
||||
domain: b.domain,
|
||||
year: b.year,
|
||||
month: b.month,
|
||||
ym: b.ym,
|
||||
inbox_count: b.emails.size,
|
||||
amount_usd: b.emails.size * PRICE_PER_INBOX_USD,
|
||||
inbox_emails: [...b.emails].sort(),
|
||||
}));
|
||||
|
||||
// Newest months first, then domain name.
|
||||
result.sort((a, b) => {
|
||||
if (a.ym !== b.ym) return a.ym < b.ym ? 1 : -1;
|
||||
return a.domain.localeCompare(b.domain);
|
||||
});
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
export const PRICE_PER_INBOX = PRICE_PER_INBOX_USD;
|
||||
Reference in New Issue
Block a user