From f097f96d06379af5d21871d49792ab1198cf18b0 Mon Sep 17 00:00:00 2001 From: Andreas Knuth Date: Tue, 28 Apr 2026 17:59:51 -0500 Subject: [PATCH] billing --- .../migrations/003_mailbox_billing_events.sql | 59 ++++ backend/src/routes/billing.ts | 49 ++++ backend/src/routes/mailboxes.ts | 5 +- backend/src/server.ts | 3 +- backend/src/services/billing.ts | 208 +++++++++++++ frontend/src/App.jsx | 18 +- frontend/src/components/BillingModal.jsx | 274 ++++++++++++++++++ frontend/src/services/api.js | 19 +- 8 files changed, 628 insertions(+), 7 deletions(-) create mode 100644 backend/migrations/003_mailbox_billing_events.sql create mode 100644 backend/src/routes/billing.ts create mode 100644 backend/src/services/billing.ts create mode 100644 frontend/src/components/BillingModal.jsx diff --git a/backend/migrations/003_mailbox_billing_events.sql b/backend/migrations/003_mailbox_billing_events.sql new file mode 100644 index 0000000..15c48f9 --- /dev/null +++ b/backend/migrations/003_mailbox_billing_events.sql @@ -0,0 +1,59 @@ +-- ============================================================ +-- 003_mailbox_billing_events.sql +-- Append-only event log for inbox billing. +-- +-- Each row records that a mailbox was created or deleted at a +-- specific point in time. The aggregation per month is computed +-- on the fly from these events. +-- ============================================================ + +CREATE TABLE IF NOT EXISTS mailbox_billing_events ( + id BIGSERIAL PRIMARY KEY, + occurred_at TIMESTAMPTZ NOT NULL DEFAULT now(), + domain TEXT NOT NULL, + email TEXT NOT NULL, + action TEXT NOT NULL CHECK (action IN ('created', 'deleted')), + actor_email TEXT, + notes TEXT +); + +CREATE INDEX IF NOT EXISTS idx_billing_events_domain_time + ON mailbox_billing_events(domain, occurred_at); + +CREATE INDEX IF NOT EXISTS idx_billing_events_email + ON mailbox_billing_events(email); + +-- ------------------------------------------------------------ +-- Backfill: synthesize 'created' events for every mailbox that +-- already exists when this migration runs, so we have correct +-- historical data right from the first deploy. +-- +-- We use the mailbox's created_at as the event timestamp. +-- For mailboxes that are already soft-deleted, also synthesize +-- the matching 'deleted' event using deleted_at. +-- +-- The WHERE NOT EXISTS guards make this migration idempotent +-- in case it is re-run. +-- ------------------------------------------------------------ + +INSERT INTO mailbox_billing_events (occurred_at, domain, email, action, actor_email, notes) +SELECT m.created_at, m.domain, m.email_address, 'created', NULL, 'backfill from migration 003' +FROM mailboxes m +WHERE NOT EXISTS ( + SELECT 1 FROM mailbox_billing_events b + WHERE b.email = m.email_address + AND b.action = 'created' + AND b.occurred_at = m.created_at +); + +INSERT INTO mailbox_billing_events (occurred_at, domain, email, action, actor_email, notes) +SELECT m.deleted_at, m.domain, m.email_address, 'deleted', NULL, 'backfill from migration 003' +FROM mailboxes m +WHERE m.status = 'deleted' + AND m.deleted_at IS NOT NULL + AND NOT EXISTS ( + SELECT 1 FROM mailbox_billing_events b + WHERE b.email = m.email_address + AND b.action = 'deleted' + AND b.occurred_at = m.deleted_at + ); diff --git a/backend/src/routes/billing.ts b/backend/src/routes/billing.ts new file mode 100644 index 0000000..2a87e3b --- /dev/null +++ b/backend/src/routes/billing.ts @@ -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); +}); diff --git a/backend/src/routes/mailboxes.ts b/backend/src/routes/mailboxes.ts index 72c8e9c..811a517 100644 --- a/backend/src/routes/mailboxes.ts +++ b/backend/src/routes/mailboxes.ts @@ -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); diff --git a/backend/src/server.ts b/backend/src/server.ts index 7ae8fec..e0ce853 100644 --- a/backend/src/server.ts +++ b/backend/src/server.ts @@ -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') || diff --git a/backend/src/services/billing.ts b/backend/src/services/billing.ts new file mode 100644 index 0000000..e921fc0 --- /dev/null +++ b/backend/src/services/billing.ts @@ -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 { + 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 { + 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 { + // 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(); + 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 }; + const buckets = new Map(); + + 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; diff --git a/frontend/src/App.jsx b/frontend/src/App.jsx index 28aba5e..b9e44db 100644 --- a/frontend/src/App.jsx +++ b/frontend/src/App.jsx @@ -1,7 +1,7 @@ import React, { useCallback, useEffect, useState } from 'react'; import { FiRefreshCw, FiList, FiLogOut, FiSettings, FiKey, FiTrash2, FiPlus, FiInbox, - FiUsers, FiUser, FiHardDrive, + FiUsers, FiUser, FiHardDrive, FiDollarSign, } from 'react-icons/fi'; import Login from './components/Login'; @@ -16,6 +16,7 @@ import AuditLogModal from './components/AuditLogModal'; import AdminUsersModal from './components/AdminUsersModal'; import ChangeMyPasswordModal from './components/ChangeMyPasswordModal'; import DomainQuotaModal from './components/DomainQuotaModal'; +import BillingModal from './components/BillingModal'; import { authAPI, domainsAPI, mailboxesAPI } from './services/api'; @@ -38,6 +39,7 @@ function App() { const [showAdmins, setShowAdmins] = useState(false); const [showChangePw, setShowChangePw] = useState(false); const [showDomainQuota, setShowDomainQuota] = useState(false); + const [showBilling, setShowBilling] = useState(false); const showToast = useCallback((message, type = 'success') => { setToast({ message, type }); @@ -193,9 +195,12 @@ function App() { My Password - {/* Audit log: super_admin only. */} {isSuperAdmin && ( <> + + ); + })} + + + + {/* Domain filter */} +
+ + +
+ + {loading && } + + {!loading && activeTab === 'summary' && ( + + )} + + {!loading && activeTab === 'events' && ( + + )} + + + ); +}; + +// ============================================================ +// Sub: Summary +// ============================================================ +const SummaryView = ({ summary }) => { + if (!summary) return null; + + const { months, price_per_inbox } = summary; + + if (!months || months.length === 0) { + return ( +

+ No billable activity yet. +

+ ); + } + + // Group by year-month for the section headers, then list domains within. + const byYm = new Map(); + for (const row of months) { + if (!byYm.has(row.ym)) byYm.set(row.ym, []); + byYm.get(row.ym).push(row); + } + + // Total across the most recent month for the headline number. + const sortedYms = [...byYm.keys()].sort().reverse(); + const latestYm = sortedYms[0]; + const latestRows = byYm.get(latestYm) || []; + const latestTotalInboxes = latestRows.reduce((s, r) => s + r.inbox_count, 0); + const latestTotalUsd = latestRows.reduce((s, r) => s + r.amount_usd, 0); + const [latestY, latestM] = latestYm ? latestYm.split('-').map(Number) : [null, null]; + + return ( +
+ {/* Headline */} + {latestYm && ( +
+
+ {MONTH_NAMES[latestM - 1]} {latestY} +
+
+ ${latestTotalUsd} + + ({latestTotalInboxes} inbox{latestTotalInboxes !== 1 ? 'es' : ''} × ${price_per_inbox}) + +
+
+ )} + + {/* Per month / per domain */} +
+ {sortedYms.map((ym) => { + const rows = byYm.get(ym); + const [y, m] = ym.split('-').map(Number); + const monthTotalInboxes = rows.reduce((s, r) => s + r.inbox_count, 0); + const monthTotalUsd = rows.reduce((s, r) => s + r.amount_usd, 0); + + return ( +
+
+

+ {MONTH_NAMES[m - 1]} {y} +

+ + {monthTotalInboxes} inboxes ·{' '} + ${monthTotalUsd} + +
+ + + {rows + .sort((a, b) => b.amount_usd - a.amount_usd || a.domain.localeCompare(b.domain)) + .map((r) => ( + + + + + + ))} + +
+ {r.domain} + + + {r.inbox_count} inbox{r.inbox_count !== 1 ? 'es' : ''} + + ${r.amount_usd} +
+
+ ); + })} +
+
+ ); +}; + +// ============================================================ +// Sub: Events +// ============================================================ +const EventsView = ({ events }) => { + if (!events || events.length === 0) { + return ( +

+ No events yet. +

+ ); + } + + return ( +
+ + + + + + + + + + + + {events.map((ev) => ( + + + + + + + + ))} + +
WhenActionMailboxDomainActor
+ {new Date(ev.occurred_at).toLocaleString()} + + {ev.action === 'created' ? ( + + created + + ) : ( + + deleted + + )} + {ev.email}{ev.domain} + {ev.actor_email || (ev.notes ? {ev.notes} : '—')} +
+
+ ); +}; + +export default BillingModal; diff --git a/frontend/src/services/api.js b/frontend/src/services/api.js index b20acac..5422871 100644 --- a/frontend/src/services/api.js +++ b/frontend/src/services/api.js @@ -50,7 +50,6 @@ export const mailboxesAPI = { `/api/mailboxes/${encodeURIComponent(email)}/password`, { password } )).data, - // quota_gb: integer, e.g. 5, 10, 15... setQuota: async (email, quota_gb) => (await api.post( `/api/mailboxes/${encodeURIComponent(email)}/quota`, @@ -85,3 +84,21 @@ export const adminsAPI = { remove: async (email) => (await api.delete(`/api/admins/${encodeURIComponent(email)}`)).data, }; + +export const billingAPI = { + summary: async (domain) => { + const params = new URLSearchParams(); + if (domain) params.set('domain', domain); + const qs = params.toString(); + return (await api.get(`/api/billing/summary${qs ? '?' + qs : ''}`)).data; + }, + events: async ({ domain, from, to, limit } = {}) => { + const params = new URLSearchParams(); + if (domain) params.set('domain', domain); + if (from) params.set('from', from); + if (to) params.set('to', to); + if (limit) params.set('limit', String(limit)); + const qs = params.toString(); + return (await api.get(`/api/billing/events${qs ? '?' + qs : ''}`)).data; + }, +};