mirror of
https://github.com/diegosouzapw/OmniRoute.git
synced 2026-09-14 10:52:17 +03:00
632 lines
19 KiB
TypeScript
632 lines
19 KiB
TypeScript
/**
|
|
* Cost Rules — Domain Layer (T-19)
|
|
*
|
|
* Business rules for cost management: budget thresholds,
|
|
* scheduled reset windows, and cost summaries per API key.
|
|
*
|
|
* State is persisted in SQLite via domainState.ts.
|
|
*
|
|
* @module domain/costRules
|
|
*/
|
|
|
|
import {
|
|
deleteAllCostData,
|
|
deleteBudget as dbDeleteBudget,
|
|
deleteCostEntries,
|
|
loadAllBudgets,
|
|
loadBudget,
|
|
loadCostEntries,
|
|
loadCostEntriesInRange,
|
|
loadCostTotal,
|
|
saveBudget,
|
|
saveBudgetResetLog,
|
|
} from "../lib/db/domainState";
|
|
import {
|
|
discardSpendBatchEntries,
|
|
resetSpendBatchWriterForTests,
|
|
spendBatchWriter,
|
|
} from "@/lib/spend/batchWriter";
|
|
|
|
export type BudgetResetInterval = "daily" | "weekly" | "monthly";
|
|
|
|
interface BudgetConfig {
|
|
dailyLimitUsd?: number;
|
|
weeklyLimitUsd?: number;
|
|
monthlyLimitUsd?: number;
|
|
warningThreshold?: number;
|
|
resetInterval?: BudgetResetInterval;
|
|
resetTime?: string;
|
|
budgetResetAt?: number | null;
|
|
lastBudgetResetAt?: number | null;
|
|
warningEmittedAt?: number | null;
|
|
warningPeriodStart?: number | null;
|
|
}
|
|
|
|
interface NormalizedBudgetConfig {
|
|
dailyLimitUsd: number;
|
|
weeklyLimitUsd: number;
|
|
monthlyLimitUsd: number;
|
|
warningThreshold: number;
|
|
resetInterval: BudgetResetInterval;
|
|
resetTime: string;
|
|
budgetResetAt: number | null;
|
|
lastBudgetResetAt: number | null;
|
|
warningEmittedAt: number | null;
|
|
warningPeriodStart: number | null;
|
|
}
|
|
|
|
interface CostEntry {
|
|
cost: number;
|
|
timestamp: number;
|
|
}
|
|
|
|
interface BudgetWindow {
|
|
periodStartAt: number;
|
|
nextResetAt: number;
|
|
}
|
|
|
|
interface SyncBudgetScheduleOptions {
|
|
logReset?: boolean;
|
|
persist?: boolean;
|
|
}
|
|
|
|
interface BudgetSummary {
|
|
dailyTotal: number;
|
|
monthlyTotal: number;
|
|
totalEntries: number;
|
|
budget: NormalizedBudgetConfig | null;
|
|
totalCostToday: number;
|
|
totalCostMonth: number;
|
|
totalCostPeriod: number;
|
|
activeLimitUsd: number;
|
|
resetInterval: BudgetResetInterval | null;
|
|
resetTime: string | null;
|
|
budgetResetAt: number | null;
|
|
lastBudgetResetAt: number | null;
|
|
periodStartAt: number | null;
|
|
nextResetAt: number | null;
|
|
dailyLimitUsd: number;
|
|
weeklyLimitUsd: number;
|
|
monthlyLimitUsd: number;
|
|
warningThreshold: number | null;
|
|
}
|
|
|
|
const VALID_RESET_INTERVALS = new Set<BudgetResetInterval>(["daily", "weekly", "monthly"]);
|
|
const RESET_TIME_REGEX = /^(\d{2}):(\d{2})$/;
|
|
|
|
/** @type {Map<string, NormalizedBudgetConfig>} In-memory cache for budgets */
|
|
const budgets = new Map<string, NormalizedBudgetConfig>();
|
|
|
|
function toNumber(value: unknown, fallback = 0): number {
|
|
if (typeof value === "number" && Number.isFinite(value)) return value;
|
|
if (typeof value === "string" && value.trim().length > 0) {
|
|
const parsed = Number(value);
|
|
return Number.isFinite(parsed) ? parsed : fallback;
|
|
}
|
|
return fallback;
|
|
}
|
|
|
|
function toCostEntries(value: unknown): CostEntry[] {
|
|
if (!Array.isArray(value)) return [];
|
|
const entries: CostEntry[] = [];
|
|
for (const item of value) {
|
|
if (!item || typeof item !== "object" || Array.isArray(item)) continue;
|
|
const record = item as Record<string, unknown>;
|
|
const cost = toNumber(record.cost, Number.NaN);
|
|
const timestamp = toNumber(record.timestamp, Number.NaN);
|
|
if (!Number.isFinite(cost) || !Number.isFinite(timestamp)) continue;
|
|
entries.push({ cost, timestamp });
|
|
}
|
|
return entries;
|
|
}
|
|
|
|
function sumEntries(entries: CostEntry[]): number {
|
|
return entries.reduce((sum, entry) => sum + entry.cost, 0);
|
|
}
|
|
|
|
function normalizeResetInterval(value: unknown): BudgetResetInterval {
|
|
if (typeof value === "string") {
|
|
const normalized = value.trim().toLowerCase() as BudgetResetInterval;
|
|
if (VALID_RESET_INTERVALS.has(normalized)) {
|
|
return normalized;
|
|
}
|
|
}
|
|
return "daily";
|
|
}
|
|
|
|
function normalizeResetTime(value: unknown): string {
|
|
if (typeof value === "string") {
|
|
const match = value.trim().match(RESET_TIME_REGEX);
|
|
if (match) {
|
|
const hours = Math.min(Math.max(parseInt(match[1], 10), 0), 23);
|
|
const minutes = Math.min(Math.max(parseInt(match[2], 10), 0), 59);
|
|
return `${String(hours).padStart(2, "0")}:${String(minutes).padStart(2, "0")}`;
|
|
}
|
|
}
|
|
return "00:00";
|
|
}
|
|
|
|
function normalizeTimestamp(value: unknown): number | null {
|
|
const numeric = toNumber(value, Number.NaN);
|
|
return Number.isFinite(numeric) && numeric > 0 ? numeric : null;
|
|
}
|
|
|
|
function normalizeBudgetConfig(config: BudgetConfig): NormalizedBudgetConfig {
|
|
return {
|
|
dailyLimitUsd: Math.max(0, toNumber(config.dailyLimitUsd)),
|
|
weeklyLimitUsd: Math.max(0, toNumber(config.weeklyLimitUsd)),
|
|
monthlyLimitUsd: Math.max(0, toNumber(config.monthlyLimitUsd)),
|
|
warningThreshold: Math.min(Math.max(toNumber(config.warningThreshold, 0.8), 0), 1),
|
|
resetInterval: normalizeResetInterval(config.resetInterval),
|
|
resetTime: normalizeResetTime(config.resetTime),
|
|
budgetResetAt: normalizeTimestamp(config.budgetResetAt),
|
|
lastBudgetResetAt: normalizeTimestamp(config.lastBudgetResetAt),
|
|
warningEmittedAt: normalizeTimestamp(config.warningEmittedAt),
|
|
warningPeriodStart: normalizeTimestamp(config.warningPeriodStart),
|
|
};
|
|
}
|
|
|
|
function getResetTimeParts(resetTime: string): [number, number] {
|
|
const match = resetTime.match(RESET_TIME_REGEX);
|
|
if (!match) return [0, 0];
|
|
return [parseInt(match[1], 10), parseInt(match[2], 10)];
|
|
}
|
|
|
|
function getUtcDateMs(year: number, month: number, day: number, hours: number, minutes: number) {
|
|
return Date.UTC(year, month, day, hours, minutes, 0, 0);
|
|
}
|
|
|
|
export function getBudgetWindow(
|
|
resetInterval: BudgetResetInterval,
|
|
resetTime = "00:00",
|
|
now = Date.now()
|
|
): BudgetWindow {
|
|
const current = new Date(now);
|
|
const [hours, minutes] = getResetTimeParts(normalizeResetTime(resetTime));
|
|
const year = current.getUTCFullYear();
|
|
const month = current.getUTCMonth();
|
|
const day = current.getUTCDate();
|
|
|
|
if (resetInterval === "weekly") {
|
|
const daysSinceMonday = (current.getUTCDay() + 6) % 7;
|
|
const thisWeekReset = getUtcDateMs(year, month, day - daysSinceMonday, hours, minutes);
|
|
return now >= thisWeekReset
|
|
? {
|
|
periodStartAt: thisWeekReset,
|
|
nextResetAt: getUtcDateMs(year, month, day - daysSinceMonday + 7, hours, minutes),
|
|
}
|
|
: {
|
|
periodStartAt: getUtcDateMs(year, month, day - daysSinceMonday - 7, hours, minutes),
|
|
nextResetAt: thisWeekReset,
|
|
};
|
|
}
|
|
|
|
if (resetInterval === "monthly") {
|
|
const thisMonthReset = getUtcDateMs(year, month, 1, hours, minutes);
|
|
return now >= thisMonthReset
|
|
? {
|
|
periodStartAt: thisMonthReset,
|
|
nextResetAt: getUtcDateMs(year, month + 1, 1, hours, minutes),
|
|
}
|
|
: {
|
|
periodStartAt: getUtcDateMs(year, month - 1, 1, hours, minutes),
|
|
nextResetAt: thisMonthReset,
|
|
};
|
|
}
|
|
|
|
const todayReset = getUtcDateMs(year, month, day, hours, minutes);
|
|
return now >= todayReset
|
|
? {
|
|
periodStartAt: todayReset,
|
|
nextResetAt: getUtcDateMs(year, month, day + 1, hours, minutes),
|
|
}
|
|
: {
|
|
periodStartAt: getUtcDateMs(year, month, day - 1, hours, minutes),
|
|
nextResetAt: todayReset,
|
|
};
|
|
}
|
|
|
|
function getActiveBudgetLimit(budget: NormalizedBudgetConfig): number {
|
|
if (budget.resetInterval === "monthly") {
|
|
return budget.monthlyLimitUsd > 0 ? budget.monthlyLimitUsd : budget.dailyLimitUsd;
|
|
}
|
|
if (budget.resetInterval === "weekly") {
|
|
return budget.weeklyLimitUsd > 0 ? budget.weeklyLimitUsd : budget.dailyLimitUsd;
|
|
}
|
|
return budget.dailyLimitUsd;
|
|
}
|
|
|
|
function getBudgetWindowTotal(apiKeyId: string, periodStartAt: number): number {
|
|
try {
|
|
return (
|
|
loadCostTotal(apiKeyId, periodStartAt) +
|
|
spendBatchWriter.getPendingCostTotal(apiKeyId, periodStartAt)
|
|
);
|
|
} catch {
|
|
return 0;
|
|
}
|
|
}
|
|
|
|
function getBudgetWindowRangeTotal(
|
|
apiKeyId: string,
|
|
periodStartAt: number,
|
|
periodEndAt: number
|
|
): number {
|
|
try {
|
|
return (
|
|
sumEntries(toCostEntries(loadCostEntriesInRange(apiKeyId, periodStartAt, periodEndAt))) +
|
|
spendBatchWriter.getPendingCostTotal(apiKeyId, periodStartAt, periodEndAt)
|
|
);
|
|
} catch {
|
|
return 0;
|
|
}
|
|
}
|
|
|
|
function emitBudgetWarning(
|
|
apiKeyId: string,
|
|
budget: NormalizedBudgetConfig,
|
|
projectedTotal: number,
|
|
activeLimitUsd: number,
|
|
nextResetAt: number
|
|
) {
|
|
const percentage =
|
|
activeLimitUsd > 0 ? ((projectedTotal / activeLimitUsd) * 100).toFixed(1) : "0.0";
|
|
console.warn(
|
|
`[BudgetWarning] ${apiKeyId} reached ${percentage}% of ${budget.resetInterval} budget ($${projectedTotal.toFixed(4)} / $${activeLimitUsd.toFixed(2)}) — next reset ${new Date(nextResetAt).toISOString()}`
|
|
);
|
|
}
|
|
|
|
function syncBudgetSchedule(
|
|
apiKeyId: string,
|
|
config: BudgetConfig,
|
|
now = Date.now(),
|
|
options: SyncBudgetScheduleOptions = {}
|
|
): NormalizedBudgetConfig {
|
|
const normalized = normalizeBudgetConfig(config);
|
|
const window = getBudgetWindow(normalized.resetInterval, normalized.resetTime, now);
|
|
const resetRolled =
|
|
normalized.lastBudgetResetAt !== null && window.periodStartAt > normalized.lastBudgetResetAt;
|
|
|
|
if (resetRolled && options.logReset !== false) {
|
|
const previousSpend = getBudgetWindowRangeTotal(
|
|
apiKeyId,
|
|
normalized.lastBudgetResetAt as number,
|
|
window.periodStartAt
|
|
);
|
|
try {
|
|
saveBudgetResetLog({
|
|
apiKeyId,
|
|
resetInterval: normalized.resetInterval,
|
|
previousSpend,
|
|
resetAt: window.periodStartAt,
|
|
nextResetAt: window.nextResetAt,
|
|
periodStart: window.periodStartAt,
|
|
periodEnd: window.nextResetAt,
|
|
});
|
|
} catch {
|
|
// Non-fatal: budget logic still proceeds even if logging fails.
|
|
}
|
|
}
|
|
|
|
const updated: NormalizedBudgetConfig = {
|
|
...normalized,
|
|
budgetResetAt: window.nextResetAt,
|
|
lastBudgetResetAt: window.periodStartAt,
|
|
warningEmittedAt: resetRolled ? null : normalized.warningEmittedAt,
|
|
warningPeriodStart: resetRolled ? null : normalized.warningPeriodStart,
|
|
};
|
|
|
|
const changed =
|
|
normalized.budgetResetAt !== updated.budgetResetAt ||
|
|
normalized.lastBudgetResetAt !== updated.lastBudgetResetAt ||
|
|
normalized.warningEmittedAt !== updated.warningEmittedAt ||
|
|
normalized.warningPeriodStart !== updated.warningPeriodStart ||
|
|
normalized.dailyLimitUsd !== updated.dailyLimitUsd ||
|
|
normalized.weeklyLimitUsd !== updated.weeklyLimitUsd ||
|
|
normalized.monthlyLimitUsd !== updated.monthlyLimitUsd ||
|
|
normalized.warningThreshold !== updated.warningThreshold ||
|
|
normalized.resetInterval !== updated.resetInterval ||
|
|
normalized.resetTime !== updated.resetTime;
|
|
|
|
if (changed && options.persist !== false) {
|
|
try {
|
|
saveBudget(apiKeyId, updated);
|
|
} catch {
|
|
// Non-critical: in-memory cache still works.
|
|
}
|
|
}
|
|
|
|
budgets.set(apiKeyId, updated);
|
|
return updated;
|
|
}
|
|
|
|
/**
|
|
* Set budget for an API key.
|
|
*
|
|
* @param {string} apiKeyId
|
|
* @param {BudgetConfig} config
|
|
*/
|
|
export function setBudget(apiKeyId: string, config: BudgetConfig) {
|
|
return syncBudgetSchedule(apiKeyId, config, Date.now(), { logReset: false, persist: true });
|
|
}
|
|
|
|
/**
|
|
* Get budget config for an API key.
|
|
*
|
|
* @param {string} apiKeyId
|
|
* @returns {NormalizedBudgetConfig | null}
|
|
*/
|
|
export function getBudget(apiKeyId: string): NormalizedBudgetConfig | null {
|
|
const cached = budgets.get(apiKeyId);
|
|
if (cached) {
|
|
return syncBudgetSchedule(apiKeyId, cached);
|
|
}
|
|
|
|
try {
|
|
const fromDb = loadBudget(apiKeyId) as BudgetConfig | null;
|
|
if (fromDb) {
|
|
return syncBudgetSchedule(apiKeyId, fromDb);
|
|
}
|
|
} catch {
|
|
// DB may not be ready.
|
|
}
|
|
|
|
return null;
|
|
}
|
|
|
|
/**
|
|
* Delete budget config and recorded spend for an API key.
|
|
*
|
|
* @param {string} apiKeyId
|
|
*/
|
|
export function deleteBudget(apiKeyId: string) {
|
|
budgets.delete(apiKeyId);
|
|
discardSpendBatchEntries(apiKeyId);
|
|
try {
|
|
dbDeleteBudget(apiKeyId);
|
|
deleteCostEntries(apiKeyId);
|
|
} catch {
|
|
// Non-critical.
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Record a cost for an API key.
|
|
*
|
|
* @param {string} apiKeyId
|
|
* @param {number} cost - Cost in USD
|
|
*/
|
|
export function recordCost(apiKeyId: string, cost: number): void {
|
|
try {
|
|
spendBatchWriter.increment(apiKeyId, cost, Date.now());
|
|
} catch {
|
|
// Non-critical.
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Sync all budgets against the current clock so overdue resets get persisted.
|
|
*/
|
|
export function syncAllBudgetSchedules(now = Date.now()) {
|
|
let processed = 0;
|
|
let resetCount = 0;
|
|
|
|
try {
|
|
const allBudgets = loadAllBudgets();
|
|
for (const [apiKeyId, budget] of Object.entries(allBudgets)) {
|
|
processed += 1;
|
|
const synced = syncBudgetSchedule(apiKeyId, budget, now, { logReset: true, persist: true });
|
|
if (budget.lastBudgetResetAt !== synced.lastBudgetResetAt) {
|
|
resetCount += 1;
|
|
}
|
|
}
|
|
} catch {
|
|
// Non-critical.
|
|
}
|
|
|
|
return { processed, resetCount };
|
|
}
|
|
|
|
/**
|
|
* Check if an API key has remaining budget.
|
|
*
|
|
* @param {string} apiKeyId
|
|
* @param {number} [additionalCost=0] - Projected cost to check
|
|
* @returns {{ allowed: boolean, reason?: string, dailyUsed: number, dailyLimit: number, warningReached: boolean, remaining: number, periodUsed: number, activeLimitUsd: number, resetInterval: BudgetResetInterval | null, resetTime: string | null, budgetResetAt: number | null, lastBudgetResetAt: number | null, periodStartAt: number | null }}
|
|
*/
|
|
export function checkBudget(apiKeyId: string, additionalCost = 0) {
|
|
const budget = getBudget(apiKeyId);
|
|
if (!budget) {
|
|
return {
|
|
allowed: true,
|
|
dailyUsed: 0,
|
|
dailyLimit: 0,
|
|
warningReached: false,
|
|
remaining: 0,
|
|
periodUsed: 0,
|
|
activeLimitUsd: 0,
|
|
resetInterval: null,
|
|
resetTime: null,
|
|
budgetResetAt: null,
|
|
lastBudgetResetAt: null,
|
|
periodStartAt: null,
|
|
};
|
|
}
|
|
|
|
const window = getBudgetWindow(budget.resetInterval, budget.resetTime);
|
|
const periodUsed = getBudgetWindowTotal(apiKeyId, window.periodStartAt);
|
|
const projectedTotal = periodUsed + additionalCost;
|
|
const activeLimitUsd = getActiveBudgetLimit(budget);
|
|
const warningReached =
|
|
activeLimitUsd > 0 && projectedTotal >= activeLimitUsd * budget.warningThreshold;
|
|
const remaining = Math.max(activeLimitUsd - projectedTotal, 0);
|
|
|
|
if (warningReached && budget.warningPeriodStart !== window.periodStartAt) {
|
|
const updatedBudget = {
|
|
...budget,
|
|
warningEmittedAt: Date.now(),
|
|
warningPeriodStart: window.periodStartAt,
|
|
};
|
|
budgets.set(apiKeyId, updatedBudget);
|
|
try {
|
|
saveBudget(apiKeyId, updatedBudget);
|
|
emitBudgetWarning(
|
|
apiKeyId,
|
|
updatedBudget,
|
|
projectedTotal,
|
|
activeLimitUsd,
|
|
window.nextResetAt
|
|
);
|
|
} catch {
|
|
// Non-critical.
|
|
}
|
|
}
|
|
|
|
if (activeLimitUsd > 0 && projectedTotal > activeLimitUsd) {
|
|
return {
|
|
allowed: false,
|
|
reason: `${budget.resetInterval[0].toUpperCase()}${budget.resetInterval.slice(1)} budget exceeded: $${projectedTotal.toFixed(4)} / $${activeLimitUsd.toFixed(2)}`,
|
|
dailyUsed: periodUsed,
|
|
dailyLimit: activeLimitUsd,
|
|
warningReached: true,
|
|
remaining,
|
|
periodUsed,
|
|
activeLimitUsd,
|
|
resetInterval: budget.resetInterval,
|
|
resetTime: budget.resetTime,
|
|
budgetResetAt: window.nextResetAt,
|
|
lastBudgetResetAt: window.periodStartAt,
|
|
periodStartAt: window.periodStartAt,
|
|
};
|
|
}
|
|
|
|
return {
|
|
allowed: true,
|
|
dailyUsed: periodUsed,
|
|
dailyLimit: activeLimitUsd,
|
|
warningReached,
|
|
remaining,
|
|
periodUsed,
|
|
activeLimitUsd,
|
|
resetInterval: budget.resetInterval,
|
|
resetTime: budget.resetTime,
|
|
budgetResetAt: window.nextResetAt,
|
|
lastBudgetResetAt: window.periodStartAt,
|
|
periodStartAt: window.periodStartAt,
|
|
};
|
|
}
|
|
|
|
/**
|
|
* Get daily total cost for an API key.
|
|
*
|
|
* @param {string} apiKeyId
|
|
* @returns {number} Total cost today in USD
|
|
*/
|
|
export function getDailyTotal(apiKeyId: string): number {
|
|
const todayStart = new Date();
|
|
todayStart.setHours(0, 0, 0, 0);
|
|
|
|
try {
|
|
return (
|
|
sumEntries(toCostEntries(loadCostEntries(apiKeyId, todayStart.getTime()))) +
|
|
spendBatchWriter.getPendingCostTotal(apiKeyId, todayStart.getTime())
|
|
);
|
|
} catch {
|
|
return 0;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Get cost summary for an API key.
|
|
*
|
|
* @param {string} apiKeyId
|
|
* @returns {BudgetSummary}
|
|
*/
|
|
export function getCostSummary(apiKeyId: string): BudgetSummary {
|
|
const now = new Date();
|
|
const todayStart = new Date(now);
|
|
todayStart.setHours(0, 0, 0, 0);
|
|
const monthStart = new Date(now.getFullYear(), now.getMonth(), 1);
|
|
|
|
const budget = getBudget(apiKeyId);
|
|
const window = budget ? getBudgetWindow(budget.resetInterval, budget.resetTime) : null;
|
|
|
|
try {
|
|
const dailyEntries = [
|
|
...toCostEntries(loadCostEntries(apiKeyId, todayStart.getTime())),
|
|
...spendBatchWriter.getBufferedEntries(apiKeyId, todayStart.getTime()),
|
|
];
|
|
const monthlyEntries = [
|
|
...toCostEntries(loadCostEntries(apiKeyId, monthStart.getTime())),
|
|
...spendBatchWriter.getBufferedEntries(apiKeyId, monthStart.getTime()),
|
|
];
|
|
const periodEntries =
|
|
window !== null
|
|
? [
|
|
...toCostEntries(loadCostEntries(apiKeyId, window.periodStartAt)),
|
|
...spendBatchWriter.getBufferedEntries(apiKeyId, window.periodStartAt),
|
|
]
|
|
: [];
|
|
|
|
const dailyTotal = sumEntries(dailyEntries);
|
|
const monthlyTotal = sumEntries(monthlyEntries);
|
|
const periodTotal = sumEntries(periodEntries);
|
|
const activeLimitUsd = budget ? getActiveBudgetLimit(budget) : 0;
|
|
|
|
return {
|
|
dailyTotal,
|
|
monthlyTotal,
|
|
totalEntries: monthlyEntries.length,
|
|
budget,
|
|
totalCostToday: dailyTotal,
|
|
totalCostMonth: monthlyTotal,
|
|
totalCostPeriod: periodTotal,
|
|
activeLimitUsd,
|
|
resetInterval: budget?.resetInterval ?? null,
|
|
resetTime: budget?.resetTime ?? null,
|
|
budgetResetAt: window?.nextResetAt ?? budget?.budgetResetAt ?? null,
|
|
lastBudgetResetAt: window?.periodStartAt ?? budget?.lastBudgetResetAt ?? null,
|
|
periodStartAt: window?.periodStartAt ?? null,
|
|
nextResetAt: window?.nextResetAt ?? null,
|
|
dailyLimitUsd: budget?.dailyLimitUsd ?? 0,
|
|
weeklyLimitUsd: budget?.weeklyLimitUsd ?? 0,
|
|
monthlyLimitUsd: budget?.monthlyLimitUsd ?? 0,
|
|
warningThreshold: budget?.warningThreshold ?? null,
|
|
};
|
|
} catch {
|
|
return {
|
|
dailyTotal: 0,
|
|
monthlyTotal: 0,
|
|
totalEntries: 0,
|
|
budget,
|
|
totalCostToday: 0,
|
|
totalCostMonth: 0,
|
|
totalCostPeriod: 0,
|
|
activeLimitUsd: budget ? getActiveBudgetLimit(budget) : 0,
|
|
resetInterval: budget?.resetInterval ?? null,
|
|
resetTime: budget?.resetTime ?? null,
|
|
budgetResetAt: budget?.budgetResetAt ?? null,
|
|
lastBudgetResetAt: budget?.lastBudgetResetAt ?? null,
|
|
periodStartAt: budget?.lastBudgetResetAt ?? null,
|
|
nextResetAt: budget?.budgetResetAt ?? null,
|
|
dailyLimitUsd: budget?.dailyLimitUsd ?? 0,
|
|
weeklyLimitUsd: budget?.weeklyLimitUsd ?? 0,
|
|
monthlyLimitUsd: budget?.monthlyLimitUsd ?? 0,
|
|
warningThreshold: budget?.warningThreshold ?? null,
|
|
};
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Clear all cost data (for testing).
|
|
*/
|
|
export function resetCostData() {
|
|
budgets.clear();
|
|
resetSpendBatchWriterForTests();
|
|
try {
|
|
deleteAllCostData();
|
|
} catch {
|
|
// Non-critical.
|
|
}
|
|
}
|