billing
This commit is contained in:
59
backend/migrations/003_mailbox_billing_events.sql
Normal file
59
backend/migrations/003_mailbox_billing_events.sql
Normal file
@@ -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
|
||||||
|
);
|
||||||
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 { SyncService } from '../services/sync.js';
|
||||||
import { DynamoRulesService } from '../services/dynamodb.js';
|
import { DynamoRulesService } from '../services/dynamodb.js';
|
||||||
import { audit } from '../services/audit.js';
|
import { audit } from '../services/audit.js';
|
||||||
|
import { recordBillingEvent } from '../services/billing.js';
|
||||||
import { domainFromEmail, localPartFromEmail, normalizeEmail } from '../utils/email.js';
|
import { domainFromEmail, localPartFromEmail, normalizeEmail } from '../utils/email.js';
|
||||||
|
|
||||||
export const mailboxesRouter = Router();
|
export const mailboxesRouter = Router();
|
||||||
@@ -102,6 +103,7 @@ mailboxesRouter.post('/', async (req, res) => {
|
|||||||
await sync.syncFromDms();
|
await sync.syncFromDms();
|
||||||
await refreshQuotaForMailbox(email).catch((err) => console.warn(`Could not refresh quota for ${email}:`, err));
|
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 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) });
|
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 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 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 audit(req.user!.email, 'mailbox.delete', 'mailbox', email, { domain }, req.ip);
|
||||||
|
await recordBillingEvent('deleted', domain, email, req.user!.email);
|
||||||
res.json({ ok: true });
|
res.json({ ok: true });
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -125,8 +128,6 @@ mailboxesRouter.post('/:email/password', async (req, res) => {
|
|||||||
res.json({ ok: true });
|
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) => {
|
mailboxesRouter.post('/:email/quota', async (req, res) => {
|
||||||
const body = z.object({ quota_gb: z.number().int().min(1).max(1024) }).parse(req.body);
|
const body = z.object({ quota_gb: z.number().int().min(1).max(1024) }).parse(req.body);
|
||||||
const email = normalizeEmail(req.params.email);
|
const email = normalizeEmail(req.params.email);
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ import { domainsRouter } from './routes/domains.js';
|
|||||||
import { mailboxesRouter } from './routes/mailboxes.js';
|
import { mailboxesRouter } from './routes/mailboxes.js';
|
||||||
import { auditRouter } from './routes/audit.js';
|
import { auditRouter } from './routes/audit.js';
|
||||||
import { adminsRouter } from './routes/admins.js';
|
import { adminsRouter } from './routes/admins.js';
|
||||||
|
import { billingRouter } from './routes/billing.js';
|
||||||
import { SyncService } from './services/sync.js';
|
import { SyncService } from './services/sync.js';
|
||||||
|
|
||||||
const __dirname = dirname(fileURLToPath(import.meta.url));
|
const __dirname = dirname(fileURLToPath(import.meta.url));
|
||||||
@@ -43,6 +44,7 @@ app.use('/api/domains', domainsRouter);
|
|||||||
app.use('/api/mailboxes', mailboxesRouter);
|
app.use('/api/mailboxes', mailboxesRouter);
|
||||||
app.use('/api/audit', auditRouter);
|
app.use('/api/audit', auditRouter);
|
||||||
app.use('/api/admins', adminsRouter);
|
app.use('/api/admins', adminsRouter);
|
||||||
|
app.use('/api/billing', billingRouter);
|
||||||
|
|
||||||
app.use((err: any, req: express.Request, res: express.Response, _next: express.NextFunction) => {
|
app.use((err: any, req: express.Request, res: express.Response, _next: express.NextFunction) => {
|
||||||
const status = err.status ?? err.statusCode ?? 500;
|
const status = err.status ?? err.statusCode ?? 500;
|
||||||
@@ -61,7 +63,6 @@ const publicDir = config.publicDir.startsWith('/')
|
|||||||
|
|
||||||
console.log(`Serving frontend from: ${publicDir}`);
|
console.log(`Serving frontend from: ${publicDir}`);
|
||||||
|
|
||||||
// Avoid stale frontend JS while we are actively developing the MVP.
|
|
||||||
app.use((req, res, next) => {
|
app.use((req, res, next) => {
|
||||||
if (
|
if (
|
||||||
req.path.endsWith('.js') ||
|
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;
|
||||||
@@ -1,7 +1,7 @@
|
|||||||
import React, { useCallback, useEffect, useState } from 'react';
|
import React, { useCallback, useEffect, useState } from 'react';
|
||||||
import {
|
import {
|
||||||
FiRefreshCw, FiList, FiLogOut, FiSettings, FiKey, FiTrash2, FiPlus, FiInbox,
|
FiRefreshCw, FiList, FiLogOut, FiSettings, FiKey, FiTrash2, FiPlus, FiInbox,
|
||||||
FiUsers, FiUser, FiHardDrive,
|
FiUsers, FiUser, FiHardDrive, FiDollarSign,
|
||||||
} from 'react-icons/fi';
|
} from 'react-icons/fi';
|
||||||
|
|
||||||
import Login from './components/Login';
|
import Login from './components/Login';
|
||||||
@@ -16,6 +16,7 @@ import AuditLogModal from './components/AuditLogModal';
|
|||||||
import AdminUsersModal from './components/AdminUsersModal';
|
import AdminUsersModal from './components/AdminUsersModal';
|
||||||
import ChangeMyPasswordModal from './components/ChangeMyPasswordModal';
|
import ChangeMyPasswordModal from './components/ChangeMyPasswordModal';
|
||||||
import DomainQuotaModal from './components/DomainQuotaModal';
|
import DomainQuotaModal from './components/DomainQuotaModal';
|
||||||
|
import BillingModal from './components/BillingModal';
|
||||||
|
|
||||||
import { authAPI, domainsAPI, mailboxesAPI } from './services/api';
|
import { authAPI, domainsAPI, mailboxesAPI } from './services/api';
|
||||||
|
|
||||||
@@ -38,6 +39,7 @@ function App() {
|
|||||||
const [showAdmins, setShowAdmins] = useState(false);
|
const [showAdmins, setShowAdmins] = useState(false);
|
||||||
const [showChangePw, setShowChangePw] = useState(false);
|
const [showChangePw, setShowChangePw] = useState(false);
|
||||||
const [showDomainQuota, setShowDomainQuota] = useState(false);
|
const [showDomainQuota, setShowDomainQuota] = useState(false);
|
||||||
|
const [showBilling, setShowBilling] = useState(false);
|
||||||
|
|
||||||
const showToast = useCallback((message, type = 'success') => {
|
const showToast = useCallback((message, type = 'success') => {
|
||||||
setToast({ message, type });
|
setToast({ message, type });
|
||||||
@@ -193,9 +195,12 @@ function App() {
|
|||||||
<FiUser className="w-4 h-4 mr-2" />
|
<FiUser className="w-4 h-4 mr-2" />
|
||||||
My Password
|
My Password
|
||||||
</button>
|
</button>
|
||||||
{/* Audit log: super_admin only. */}
|
|
||||||
{isSuperAdmin && (
|
{isSuperAdmin && (
|
||||||
<>
|
<>
|
||||||
|
<button onClick={() => setShowBilling(true)} className="btn-secondary">
|
||||||
|
<FiDollarSign className="w-4 h-4 mr-2" />
|
||||||
|
Billing
|
||||||
|
</button>
|
||||||
<button onClick={() => setShowAudit(true)} className="btn-secondary">
|
<button onClick={() => setShowAudit(true)} className="btn-secondary">
|
||||||
<FiList className="w-4 h-4 mr-2" />
|
<FiList className="w-4 h-4 mr-2" />
|
||||||
Audit Log
|
Audit Log
|
||||||
@@ -274,7 +279,6 @@ function App() {
|
|||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex flex-row flex-nowrap gap-2">
|
<div className="flex flex-row flex-nowrap gap-2">
|
||||||
{/* Set domain-wide quota: super_admin only. */}
|
|
||||||
{isSuperAdmin && (
|
{isSuperAdmin && (
|
||||||
<button
|
<button
|
||||||
onClick={() => setShowDomainQuota(true)}
|
onClick={() => setShowDomainQuota(true)}
|
||||||
@@ -429,6 +433,14 @@ function App() {
|
|||||||
onToast={showToast}
|
onToast={showToast}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
{isSuperAdmin && (
|
||||||
|
<BillingModal
|
||||||
|
open={showBilling}
|
||||||
|
domains={domains}
|
||||||
|
onClose={() => setShowBilling(false)}
|
||||||
|
onToast={showToast}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
<ChangeMyPasswordModal
|
<ChangeMyPasswordModal
|
||||||
open={showChangePw}
|
open={showChangePw}
|
||||||
onClose={() => setShowChangePw(false)}
|
onClose={() => setShowChangePw(false)}
|
||||||
|
|||||||
274
frontend/src/components/BillingModal.jsx
Normal file
274
frontend/src/components/BillingModal.jsx
Normal file
@@ -0,0 +1,274 @@
|
|||||||
|
import React, { useEffect, useMemo, useState } from 'react';
|
||||||
|
import { FiDollarSign, FiList, FiPlus, FiMinus, FiInbox } from 'react-icons/fi';
|
||||||
|
import Modal from './Modal';
|
||||||
|
import LoadingOverlay from './LoadingOverlay';
|
||||||
|
import { billingAPI } from '../services/api';
|
||||||
|
|
||||||
|
const MONTH_NAMES = [
|
||||||
|
'January', 'February', 'March', 'April', 'May', 'June',
|
||||||
|
'July', 'August', 'September', 'October', 'November', 'December',
|
||||||
|
];
|
||||||
|
|
||||||
|
const TABS = [
|
||||||
|
{ id: 'summary', label: 'Monthly summary', icon: FiDollarSign },
|
||||||
|
{ id: 'events', label: 'Event log', icon: FiList },
|
||||||
|
];
|
||||||
|
|
||||||
|
const BillingModal = ({ open, domains, onClose, onToast }) => {
|
||||||
|
const [activeTab, setActiveTab] = useState('summary');
|
||||||
|
const [domainFilter, setDomainFilter] = useState(''); // '' = all
|
||||||
|
const [summary, setSummary] = useState(null);
|
||||||
|
const [events, setEvents] = useState([]);
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
|
||||||
|
const sortedDomains = useMemo(
|
||||||
|
() => [...domains].map((d) => d.domain).sort(),
|
||||||
|
[domains]
|
||||||
|
);
|
||||||
|
|
||||||
|
const reload = async () => {
|
||||||
|
if (!open) return;
|
||||||
|
setLoading(true);
|
||||||
|
try {
|
||||||
|
const filter = domainFilter || undefined;
|
||||||
|
if (activeTab === 'summary') {
|
||||||
|
const data = await billingAPI.summary(filter);
|
||||||
|
setSummary(data);
|
||||||
|
} else {
|
||||||
|
const data = await billingAPI.events({ domain: filter, limit: 500 });
|
||||||
|
setEvents(data);
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
onToast?.(`Failed to load billing data: ${err.message}`, 'error');
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (open) {
|
||||||
|
setActiveTab('summary');
|
||||||
|
setDomainFilter('');
|
||||||
|
setSummary(null);
|
||||||
|
setEvents([]);
|
||||||
|
}
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
|
}, [open]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
reload();
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
|
}, [open, activeTab, domainFilter]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Modal
|
||||||
|
open={open}
|
||||||
|
onClose={onClose}
|
||||||
|
title="Inbox billing"
|
||||||
|
subtitle="$5 per active inbox per month"
|
||||||
|
size="lg"
|
||||||
|
>
|
||||||
|
<div className="relative min-h-[400px]">
|
||||||
|
{/* Tabs */}
|
||||||
|
<div className="border-b border-gray-200 mb-4">
|
||||||
|
<div className="flex gap-1">
|
||||||
|
{TABS.map((t) => {
|
||||||
|
const Icon = t.icon;
|
||||||
|
const isActive = activeTab === t.id;
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
key={t.id}
|
||||||
|
onClick={() => setActiveTab(t.id)}
|
||||||
|
className={`flex items-center gap-2 px-4 py-3 text-sm font-medium transition-colors ${
|
||||||
|
isActive
|
||||||
|
? 'border-b-2 border-primary-600 text-primary-700 -mb-px'
|
||||||
|
: 'border-b-2 border-transparent text-gray-600 hover:text-gray-900 hover:border-gray-300'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<Icon className="w-4 h-4" />
|
||||||
|
{t.label}
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Domain filter */}
|
||||||
|
<div className="flex items-center gap-3 mb-4">
|
||||||
|
<label className="text-sm font-semibold text-gray-700">Domain:</label>
|
||||||
|
<select
|
||||||
|
value={domainFilter}
|
||||||
|
onChange={(e) => setDomainFilter(e.target.value)}
|
||||||
|
className="input-field max-w-xs py-2"
|
||||||
|
>
|
||||||
|
<option value="">All domains</option>
|
||||||
|
{sortedDomains.map((d) => (
|
||||||
|
<option key={d} value={d}>{d}</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{loading && <LoadingOverlay message="Loading..." />}
|
||||||
|
|
||||||
|
{!loading && activeTab === 'summary' && (
|
||||||
|
<SummaryView summary={summary} />
|
||||||
|
)}
|
||||||
|
|
||||||
|
{!loading && activeTab === 'events' && (
|
||||||
|
<EventsView events={events} />
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</Modal>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
// ============================================================
|
||||||
|
// Sub: Summary
|
||||||
|
// ============================================================
|
||||||
|
const SummaryView = ({ summary }) => {
|
||||||
|
if (!summary) return null;
|
||||||
|
|
||||||
|
const { months, price_per_inbox } = summary;
|
||||||
|
|
||||||
|
if (!months || months.length === 0) {
|
||||||
|
return (
|
||||||
|
<p className="text-sm text-gray-500 text-center py-12">
|
||||||
|
No billable activity yet.
|
||||||
|
</p>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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 (
|
||||||
|
<div className="space-y-6">
|
||||||
|
{/* Headline */}
|
||||||
|
{latestYm && (
|
||||||
|
<div className="bg-primary-50 border border-primary-200 rounded-lg p-4">
|
||||||
|
<div className="text-xs uppercase tracking-wide text-primary-700 font-semibold">
|
||||||
|
{MONTH_NAMES[latestM - 1]} {latestY}
|
||||||
|
</div>
|
||||||
|
<div className="mt-1 flex items-baseline gap-3">
|
||||||
|
<span className="text-3xl font-bold text-gray-900">${latestTotalUsd}</span>
|
||||||
|
<span className="text-sm text-gray-600">
|
||||||
|
({latestTotalInboxes} inbox{latestTotalInboxes !== 1 ? 'es' : ''} × ${price_per_inbox})
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Per month / per domain */}
|
||||||
|
<div className="space-y-5">
|
||||||
|
{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 (
|
||||||
|
<div key={ym}>
|
||||||
|
<div className="flex items-baseline justify-between mb-2 border-b border-gray-200 pb-1">
|
||||||
|
<h3 className="text-sm font-bold text-gray-800">
|
||||||
|
{MONTH_NAMES[m - 1]} {y}
|
||||||
|
</h3>
|
||||||
|
<span className="text-sm text-gray-600">
|
||||||
|
<span className="font-mono">{monthTotalInboxes}</span> inboxes ·{' '}
|
||||||
|
<span className="font-bold text-gray-900">${monthTotalUsd}</span>
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<table className="w-full text-sm">
|
||||||
|
<tbody>
|
||||||
|
{rows
|
||||||
|
.sort((a, b) => b.amount_usd - a.amount_usd || a.domain.localeCompare(b.domain))
|
||||||
|
.map((r) => (
|
||||||
|
<tr key={`${r.ym}-${r.domain}`} className="hover:bg-gray-50">
|
||||||
|
<td className="py-2 pr-3">
|
||||||
|
<span className="font-medium text-gray-900">{r.domain}</span>
|
||||||
|
</td>
|
||||||
|
<td className="py-2 pr-3 text-gray-600 text-xs whitespace-nowrap">
|
||||||
|
<FiInbox className="inline w-3 h-3 mr-1 mb-0.5" />
|
||||||
|
{r.inbox_count} inbox{r.inbox_count !== 1 ? 'es' : ''}
|
||||||
|
</td>
|
||||||
|
<td className="py-2 pr-3 text-right font-mono font-semibold text-gray-900 whitespace-nowrap">
|
||||||
|
${r.amount_usd}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
// ============================================================
|
||||||
|
// Sub: Events
|
||||||
|
// ============================================================
|
||||||
|
const EventsView = ({ events }) => {
|
||||||
|
if (!events || events.length === 0) {
|
||||||
|
return (
|
||||||
|
<p className="text-sm text-gray-500 text-center py-12">
|
||||||
|
No events yet.
|
||||||
|
</p>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="overflow-x-auto max-h-[60vh] custom-scrollbar">
|
||||||
|
<table className="w-full text-sm">
|
||||||
|
<thead className="text-left text-xs uppercase tracking-wide text-gray-500 border-b border-gray-200 sticky top-0 bg-white">
|
||||||
|
<tr>
|
||||||
|
<th className="py-2 pr-4 font-semibold">When</th>
|
||||||
|
<th className="py-2 pr-4 font-semibold">Action</th>
|
||||||
|
<th className="py-2 pr-4 font-semibold">Mailbox</th>
|
||||||
|
<th className="py-2 pr-4 font-semibold">Domain</th>
|
||||||
|
<th className="py-2 pr-4 font-semibold">Actor</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody className="divide-y divide-gray-100">
|
||||||
|
{events.map((ev) => (
|
||||||
|
<tr key={ev.id} className="hover:bg-gray-50 align-top">
|
||||||
|
<td className="py-2 pr-4 text-gray-500 whitespace-nowrap">
|
||||||
|
{new Date(ev.occurred_at).toLocaleString()}
|
||||||
|
</td>
|
||||||
|
<td className="py-2 pr-4">
|
||||||
|
{ev.action === 'created' ? (
|
||||||
|
<span className="inline-flex items-center gap-1 pill-success">
|
||||||
|
<FiPlus className="w-3 h-3" /> created
|
||||||
|
</span>
|
||||||
|
) : (
|
||||||
|
<span className="inline-flex items-center gap-1 pill-warn">
|
||||||
|
<FiMinus className="w-3 h-3" /> deleted
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</td>
|
||||||
|
<td className="py-2 pr-4 font-mono text-xs text-gray-800">{ev.email}</td>
|
||||||
|
<td className="py-2 pr-4 text-gray-700">{ev.domain}</td>
|
||||||
|
<td className="py-2 pr-4 text-gray-500 text-xs">
|
||||||
|
{ev.actor_email || (ev.notes ? <em>{ev.notes}</em> : '—')}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default BillingModal;
|
||||||
@@ -50,7 +50,6 @@ export const mailboxesAPI = {
|
|||||||
`/api/mailboxes/${encodeURIComponent(email)}/password`,
|
`/api/mailboxes/${encodeURIComponent(email)}/password`,
|
||||||
{ password }
|
{ password }
|
||||||
)).data,
|
)).data,
|
||||||
// quota_gb: integer, e.g. 5, 10, 15...
|
|
||||||
setQuota: async (email, quota_gb) =>
|
setQuota: async (email, quota_gb) =>
|
||||||
(await api.post(
|
(await api.post(
|
||||||
`/api/mailboxes/${encodeURIComponent(email)}/quota`,
|
`/api/mailboxes/${encodeURIComponent(email)}/quota`,
|
||||||
@@ -85,3 +84,21 @@ export const adminsAPI = {
|
|||||||
remove: async (email) =>
|
remove: async (email) =>
|
||||||
(await api.delete(`/api/admins/${encodeURIComponent(email)}`)).data,
|
(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;
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|||||||
Reference in New Issue
Block a user