Files
mailadmin/backend/src/services/billing.ts
2026-04-28 17:59:51 -05:00

209 lines
6.4 KiB
TypeScript

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;