/** * @file Spend budgets. A budget is an alert rule of type `budget` (config: * `period` day | week | month, `limit_usd`, `thresholds` in percent, optional * `tool` and `lane` scope, `tz_offset` = the browser's getTimezoneOffset() so a * period starts at the user's local midnight). This module computes a budget's * spend in its current period — priced exactly like GET /api/pricing/cost, by * the day the tokens were spent — the pace-based projection to the period's * end, and the status list behind GET /api/budgets/status. Firing lives in * `alerts.js`, once per threshold per period. * @author Nguyễn Ngọc Trí Vĩ */ const { stmts } = require("../db"); const { calculateCost, dailyTokenRows, costWhere } = require("../routes/pricing"); const PERIODS = ["day", "week", "month"]; const DAY_MS = 86_400_000; const ymd = (ms) => new Date(ms).toISOString().slice(0, 10); /** * The period containing `now`, in the user's local time. * @param {"day"|"week"|"month"} period * @param {number} now epoch ms * @param {number} tzOffset minutes, as getTimezoneOffset() (UTC+7 → -420) * @param {number} [back] periods before the current one (0 = current) * @returns {{start: string, end: string, startUtc: string, days: number, elapsed: number}} * `start`/`end` local dates (inclusive), `startUtc` the start as an ISO * instant, `elapsed` the fraction of the period gone (1 for a past period). */ function periodBounds(period, now, tzOffset = 0, back = 0) { const local = new Date(now - tzOffset * 60_000); // read with getUTC* let startLocal; let endLocal; // exclusive if (period === "day") { startLocal = Date.UTC(local.getUTCFullYear(), local.getUTCMonth(), local.getUTCDate() - back); endLocal = startLocal + DAY_MS; } else if (period === "week") { const dow = (local.getUTCDay() + 6) % 7; // Monday = 0 startLocal = Date.UTC( local.getUTCFullYear(), local.getUTCMonth(), local.getUTCDate() - dow - 7 * back ); endLocal = startLocal + 7 * DAY_MS; } else { startLocal = Date.UTC(local.getUTCFullYear(), local.getUTCMonth() - back, 1); endLocal = Date.UTC(local.getUTCFullYear(), local.getUTCMonth() - back + 1, 1); } const span = endLocal - startLocal; return { start: ymd(startLocal), end: ymd(endLocal - DAY_MS), startUtc: new Date(startLocal + tzOffset * 60_000).toISOString(), days: Math.round(span / DAY_MS), elapsed: back > 0 ? 1 : Math.min(1, Math.max(0, (local.getTime() - startLocal) / span)), }; } /** * Spend of a budget config in one period (current by default). * @returns {{period_start: string, period_end: string, spend: number, * projected: number, pct: number}} */ function budgetSpend(cfg, { now = Date.now(), back = 0, rules } = {}) { const tz = Number.isFinite(cfg.tz_offset) ? cfg.tz_offset : 0; const b = periodBounds(cfg.period, now, tz, back); // A session that spent in the period was touched in it, so older ones are // skipped before their hours are read. const extra = [{ clause: "s.updated_at >= ?", params: [b.startUtc] }]; const scope = cfg.tool ? { where: "tool = ?", params: [cfg.tool] } : null; const where = costWhere({ scope, lane: cfg.lane, extra }); const rows = dailyTokenRows(`${-tz} minutes`, where.whereClause, where.params).filter( (r) => r.date >= b.start && r.date <= b.end ); const spend = calculateCost(rows, rules || stmts.listPricing.all()).total_cost; // The pace so far, carried to the period's end; at least an hour of pace so // the first minutes of a period do not project a fortune. const projected = spend / Math.max(b.elapsed, 1 / (b.days * 24)); const round = (n) => Math.round(n * 10000) / 10000; return { period_start: b.start, period_end: b.end, spend: round(spend), projected: round(back > 0 ? spend : projected), pct: cfg.limit_usd > 0 ? round((spend / cfg.limit_usd) * 100) : 0, }; } /** The thresholds a spend has reached, lowest first. */ function crossed(cfg, pct) { return [...cfg.thresholds].sort((a, b) => a - b).filter((t) => pct >= t); } /** * Every enabled budget rule with its current-period spend. * @returns {Array} rule id/name/config + `budgetSpend` + `crossed`. */ function budgetStatus({ now = Date.now() } = {}) { const rules = stmts.listPricing.all(); return stmts.listEnabledAlertRules .all() .filter((r) => r.rule_type === "budget") .map((r) => { let cfg; try { cfg = JSON.parse(r.config || "{}"); } catch { return null; } if (!PERIODS.includes(cfg.period) || !(cfg.limit_usd > 0)) return null; const s = budgetSpend(cfg, { now, rules }); return { id: r.id, name: r.name, config: cfg, ...s, crossed: crossed(cfg, s.pct) }; }) .filter(Boolean); } module.exports = { PERIODS, periodBounds, budgetSpend, budgetStatus, crossed };