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;