diff --git a/client/src/components/Nav/SettingsTabs/Balance/AutoRefillSettings.tsx b/client/src/components/Nav/SettingsTabs/Balance/AutoRefillSettings.tsx index 38c1ae37e0..71a419ea86 100644 --- a/client/src/components/Nav/SettingsTabs/Balance/AutoRefillSettings.tsx +++ b/client/src/components/Nav/SettingsTabs/Balance/AutoRefillSettings.tsx @@ -1,100 +1,21 @@ import React from 'react'; import { Label, InfoHoverCard, ESide } from '@librechat/client'; -import { TranslationKeys, useLocalize } from '~/hooks'; +import { getRefillEligibilityDate } from 'librechat-data-provider'; -interface AutoRefillSettingsProps { - lastRefill: Date; - refillAmount: number; - refillIntervalUnit: 'seconds' | 'minutes' | 'hours' | 'days' | 'weeks' | 'months'; - refillIntervalValue: number; +import type { RefillIntervalUnit, TBalanceResponse } from 'librechat-data-provider'; +import type { TranslationKeys } from '~/hooks'; + +import { useLocalize } from '~/hooks'; + +function ensureExhaustive(value: never): void { + void value; } -/** - * Adds a time interval to a given date. - * @param {Date} date - The starting date. - * @param {number} value - The numeric value of the interval. - * @param {'seconds'|'minutes'|'hours'|'days'|'weeks'|'months'} unit - The unit of time. - * @returns {Date} A new Date representing the starting date plus the interval. - */ -const addIntervalToDate = ( - date: Date, - value: number, - unit: 'seconds' | 'minutes' | 'hours' | 'days' | 'weeks' | 'months', -): Date => { - const result = new Date(date); - switch (unit) { - case 'seconds': - result.setSeconds(result.getSeconds() + value); - break; - case 'minutes': - result.setMinutes(result.getMinutes() + value); - break; - case 'hours': - result.setHours(result.getHours() + value); - break; - case 'days': - result.setDate(result.getDate() + value); - break; - case 'weeks': - result.setDate(result.getDate() + value * 7); - break; - case 'months': - result.setMonth(result.getMonth() + value); - break; - default: - break; - } - return result; -}; - -/** - * Calculates the next future refill date. - * This function determines how many intervals have passed since the base date - * and advances to the next eligible date. It correctly handles both fixed-duration - * intervals (e.g., days, weeks) and variable-duration intervals (e.g., months). - * - * @param {Date} baseDate - The starting date for the calculation (e.g., the last refill date). - * @param {number} value - The numeric value of the interval (e.g., 7 for 7 days). - * @param {'seconds'|'minutes'|'hours'|'days'|'weeks'|'months'} unit - The unit of time for the interval. - * @returns {Date} The next calculated future refill date. - */ -function getNextFutureInterval( - baseDate: Date, - value: number, - unit: 'seconds' | 'minutes' | 'hours' | 'days' | 'weeks' | 'months', -): Date { - const now = new Date(); - - if (baseDate > now) { - return addIntervalToDate(baseDate, value, unit); - } - - if (unit === 'months') { - let nextRefillDate = new Date(baseDate); - while (nextRefillDate <= now) { - nextRefillDate = addIntervalToDate(nextRefillDate, value, unit); - } - return nextRefillDate; - } - - const intervalInMs = { - seconds: value * 1000, - minutes: value * 1000 * 60, - hours: value * 1000 * 60 * 60, - days: value * 1000 * 60 * 60 * 24, - weeks: value * 1000 * 60 * 60 * 24 * 7, - }[unit]; - - if (intervalInMs <= 0) { - return addIntervalToDate(baseDate, value, unit); - } - - const timeSinceBase = now.getTime() - baseDate.getTime(); - const intervalsPassed = Math.floor(timeSinceBase / intervalInMs); - const intervalsToNext = intervalsPassed + 1; - const nextRefillTime = baseDate.getTime() + intervalsToNext * intervalInMs; - - return new Date(nextRefillTime); +interface AutoRefillSettingsProps { + lastRefill: NonNullable; + refillAmount: number; + refillIntervalUnit: RefillIntervalUnit; + refillIntervalValue: number; } const AutoRefillSettings: React.FC = ({ @@ -106,15 +27,11 @@ const AutoRefillSettings: React.FC = ({ const localize = useLocalize(); const lastRefillDate = lastRefill ? new Date(lastRefill) : null; - const nextRefill = lastRefillDate - ? getNextFutureInterval(lastRefillDate, refillIntervalValue, refillIntervalUnit) + const refillEligibilityDate = lastRefillDate + ? getRefillEligibilityDate(lastRefillDate, refillIntervalValue, refillIntervalUnit) : null; - // Return the localized unit based on singular/plural values - const getLocalizedIntervalUnit = ( - value: number, - unit: 'seconds' | 'minutes' | 'hours' | 'days' | 'weeks' | 'months', - ): string => { + const getLocalizedIntervalUnit = (value: number, unit: RefillIntervalUnit): string => { let key: TranslationKeys; switch (unit) { case 'seconds': @@ -135,8 +52,10 @@ const AutoRefillSettings: React.FC = ({ case 'months': key = value === 1 ? 'com_nav_balance_month' : 'com_nav_balance_months'; break; - default: + default: { + ensureExhaustive(unit); key = 'com_nav_balance_seconds'; + } } return localize(key); }; @@ -160,15 +79,13 @@ const AutoRefillSettings: React.FC = ({
- {/* Left Section: Label */}
- {/* Right Section: tokenCredits Value */} - {nextRefill ? nextRefill.toLocaleString() : '-'} + {refillEligibilityDate ? refillEligibilityDate.toLocaleString() : '-'}
diff --git a/packages/api/src/middleware/checkBalance.ts b/packages/api/src/middleware/checkBalance.ts index d285614826..e5764962c2 100644 --- a/packages/api/src/middleware/checkBalance.ts +++ b/packages/api/src/middleware/checkBalance.ts @@ -1,18 +1,17 @@ import { logger } from '@librechat/data-schemas'; -import { ViolationTypes } from 'librechat-data-provider'; +import { getRefillEligibilityDate, ViolationTypes } from 'librechat-data-provider'; import type { BalanceConfig, IBalanceUpdate } from '@librechat/data-schemas'; +import type { RefillIntervalUnit } from 'librechat-data-provider'; import type { Response } from 'express'; import type { ServerRequest } from '~/types/http'; -type TimeUnit = 'seconds' | 'minutes' | 'hours' | 'days' | 'weeks' | 'months'; - interface BalanceRecord { tokenCredits: number; autoRefillEnabled?: boolean; refillAmount?: number; lastRefill?: Date; refillIntervalValue?: number; - refillIntervalUnit?: TimeUnit; + refillIntervalUnit?: RefillIntervalUnit; } interface TxData { @@ -45,33 +44,6 @@ export interface CheckBalanceDeps { upsertBalanceFields?: (userId: string, fields: IBalanceUpdate) => Promise; } -function addIntervalToDate(date: Date, value: number, unit: TimeUnit): Date { - const result = new Date(date); - switch (unit) { - case 'seconds': - result.setSeconds(result.getSeconds() + value); - break; - case 'minutes': - result.setMinutes(result.getMinutes() + value); - break; - case 'hours': - result.setHours(result.getHours() + value); - break; - case 'days': - result.setDate(result.getDate() + value); - break; - case 'weeks': - result.setDate(result.getDate() + value * 7); - break; - case 'months': - result.setMonth(result.getMonth() + value); - break; - default: - break; - } - return result; -} - /** Checks a user's balance record and handles auto-refill if needed. */ async function checkBalanceRecord( txData: TxData, @@ -148,7 +120,7 @@ async function checkBalanceRecord( if ( isNaN(lastRefillDate.getTime()) || now >= - addIntervalToDate( + getRefillEligibilityDate( lastRefillDate, record.refillIntervalValue ?? 0, record.refillIntervalUnit ?? 'days', diff --git a/packages/api/src/types/balance.ts b/packages/api/src/types/balance.ts index b5b3f0b4ea..cdf72feb07 100644 --- a/packages/api/src/types/balance.ts +++ b/packages/api/src/types/balance.ts @@ -1,9 +1,11 @@ +import type { RefillIntervalUnit } from 'librechat-data-provider'; + export interface BalanceUpdateFields { user?: string; tokenCredits?: number; autoRefillEnabled?: boolean; refillIntervalValue?: number; - refillIntervalUnit?: string; + refillIntervalUnit?: RefillIntervalUnit; refillAmount?: number; lastRefill?: Date; } diff --git a/packages/data-provider/src/balance.spec.ts b/packages/data-provider/src/balance.spec.ts new file mode 100644 index 0000000000..16472646d8 --- /dev/null +++ b/packages/data-provider/src/balance.spec.ts @@ -0,0 +1,49 @@ +import { getRefillEligibilityDate } from './balance'; +import type { RefillIntervalUnit } from './balance'; + +describe('getRefillEligibilityDate', () => { + it.each([ + ['seconds', 30, '2026-05-05T16:00:30.000Z'], + ['minutes', 15, '2026-05-05T16:15:00.000Z'], + ['hours', 2, '2026-05-05T18:00:00.000Z'], + ['days', 1, '2026-05-06T16:00:00.000Z'], + ['weeks', 1, '2026-05-12T16:00:00.000Z'], + ['months', 1, '2026-06-05T16:00:00.000Z'], + ] as const)( + 'returns lastRefill plus %s for the refill eligibility date', + (unit: RefillIntervalUnit, value: number, expected: string) => { + const lastRefill = new Date('2026-05-05T16:00:00.000Z'); + + expect(getRefillEligibilityDate(lastRefill, value, unit)).toEqual(new Date(expected)); + }, + ); + + it('computes eligibility from an old lastRefill without considering current time', () => { + const lastRefill = new Date('2026-05-03T16:00:00.000Z'); + + expect(getRefillEligibilityDate(lastRefill, 1, 'days')).toEqual( + new Date('2026-05-04T16:00:00.000Z'), + ); + }); + + it('returns the last refill date for a zero-value interval', () => { + const lastRefill = new Date('2026-05-05T16:00:00.000Z'); + + expect(getRefillEligibilityDate(lastRefill, 0, 'days')).toEqual(lastRefill); + }); + + it('documents JavaScript month-end rollover behavior', () => { + const lastRefill = new Date('2026-01-31T12:00:00.000Z'); + + expect(getRefillEligibilityDate(lastRefill, 1, 'months')).toEqual( + new Date('2026-03-03T12:00:00.000Z'), + ); + }); + + it('returns a Date fallback for unknown runtime interval units', () => { + const lastRefill = new Date('2026-05-05T16:00:00.000Z'); + const unknownUnit = 'years' as unknown as RefillIntervalUnit; + + expect(getRefillEligibilityDate(lastRefill, 1, unknownUnit)).toEqual(lastRefill); + }); +}); diff --git a/packages/data-provider/src/balance.ts b/packages/data-provider/src/balance.ts new file mode 100644 index 0000000000..5a664fbe1b --- /dev/null +++ b/packages/data-provider/src/balance.ts @@ -0,0 +1,46 @@ +export const REFILL_INTERVAL_UNITS = [ + 'seconds', + 'minutes', + 'hours', + 'days', + 'weeks', + 'months', +] as const; + +export type RefillIntervalUnit = (typeof REFILL_INTERVAL_UNITS)[number]; + +function ensureExhaustive(value: never): void { + void value; +} + +export function getRefillEligibilityDate( + lastRefill: Date, + value: number, + unit: RefillIntervalUnit, +): Date { + const result = new Date(lastRefill); + switch (unit) { + case 'seconds': + result.setSeconds(result.getSeconds() + value); + return result; + case 'minutes': + result.setMinutes(result.getMinutes() + value); + return result; + case 'hours': + result.setHours(result.getHours() + value); + return result; + case 'days': + result.setDate(result.getDate() + value); + return result; + case 'weeks': + result.setDate(result.getDate() + value * 7); + return result; + case 'months': + result.setMonth(result.getMonth() + value); + return result; + default: { + ensureExhaustive(unit); + return result; + } + } +} diff --git a/packages/data-provider/src/config.ts b/packages/data-provider/src/config.ts index d8fc071c6f..d0ae378f0b 100644 --- a/packages/data-provider/src/config.ts +++ b/packages/data-provider/src/config.ts @@ -8,6 +8,7 @@ import { fileConfigSchema } from './file-config'; import { apiBaseUrl } from './api-endpoints'; import { FileSources } from './types/files'; import { MCPServersSchema } from './mcp'; +import { REFILL_INTERVAL_UNITS } from './balance'; export const defaultSocialLogins = ['google', 'facebook', 'openid', 'github', 'discord', 'saml']; @@ -1229,10 +1230,7 @@ export const balanceSchema = z.object({ startBalance: z.number().optional().default(20000), autoRefillEnabled: z.boolean().optional().default(false), refillIntervalValue: z.number().optional().default(30), - refillIntervalUnit: z - .enum(['seconds', 'minutes', 'hours', 'days', 'weeks', 'months']) - .optional() - .default('days'), + refillIntervalUnit: z.enum(REFILL_INTERVAL_UNITS).optional().default('days'), refillAmount: z.number().optional().default(10000), }); diff --git a/packages/data-provider/src/index.ts b/packages/data-provider/src/index.ts index 08ab674849..aab5752f7d 100644 --- a/packages/data-provider/src/index.ts +++ b/packages/data-provider/src/index.ts @@ -1,6 +1,7 @@ /* config */ export * from './azure'; export * from './bedrock'; +export * from './balance'; export * from './config'; export * from './file-config'; /* messages */ diff --git a/packages/data-provider/src/types.ts b/packages/data-provider/src/types.ts index 72b17f0dcd..b1c037c94f 100644 --- a/packages/data-provider/src/types.ts +++ b/packages/data-provider/src/types.ts @@ -8,6 +8,7 @@ import type { TMessage, TBanner, } from './schemas'; +import type { RefillIntervalUnit } from './balance'; import type { SettingDefinition } from './generate'; import type { TMinimalFeedback } from './feedback'; import type { ContentTypes } from './types/runs'; @@ -683,8 +684,8 @@ export type TBalanceResponse = { // Automatic refill settings autoRefillEnabled: boolean; refillIntervalValue?: number; - refillIntervalUnit?: 'seconds' | 'minutes' | 'hours' | 'days' | 'weeks' | 'months'; - lastRefill?: Date; + refillIntervalUnit?: RefillIntervalUnit; + lastRefill?: Date | string; refillAmount?: number; }; diff --git a/packages/data-schemas/src/methods/user.ts b/packages/data-schemas/src/methods/user.ts index 3c886f8b16..8e3b632292 100644 --- a/packages/data-schemas/src/methods/user.ts +++ b/packages/data-schemas/src/methods/user.ts @@ -1,4 +1,5 @@ import mongoose, { FilterQuery } from 'mongoose'; +import type { RefillIntervalUnit } from 'librechat-data-provider'; import type { IUser, BalanceConfig, CreateUserRequest, UserDeleteResult } from '~/types'; import { signPayload } from '~/crypto'; @@ -105,7 +106,7 @@ export function createUserMethods(mongoose: typeof import('mongoose')) { $set?: { autoRefillEnabled: boolean; refillIntervalValue: number; - refillIntervalUnit: string; + refillIntervalUnit: RefillIntervalUnit; refillAmount: number; }; } = { diff --git a/packages/data-schemas/src/schema/balance.ts b/packages/data-schemas/src/schema/balance.ts index b9a65b8f4f..07c94b71f8 100644 --- a/packages/data-schemas/src/schema/balance.ts +++ b/packages/data-schemas/src/schema/balance.ts @@ -1,4 +1,5 @@ import { Schema } from 'mongoose'; +import { REFILL_INTERVAL_UNITS } from 'librechat-data-provider'; import type * as t from '~/types'; const balanceSchema = new Schema({ @@ -24,7 +25,7 @@ const balanceSchema = new Schema({ }, refillIntervalUnit: { type: String, - enum: ['seconds', 'minutes', 'hours', 'days', 'weeks', 'months'], + enum: REFILL_INTERVAL_UNITS, default: 'days', }, lastRefill: { diff --git a/packages/data-schemas/src/types/balance.ts b/packages/data-schemas/src/types/balance.ts index 54ceb0a1e9..ad14cc1464 100644 --- a/packages/data-schemas/src/types/balance.ts +++ b/packages/data-schemas/src/types/balance.ts @@ -1,4 +1,5 @@ -import { Document, Types } from 'mongoose'; +import type { RefillIntervalUnit } from 'librechat-data-provider'; +import type { Document, Types } from 'mongoose'; export interface IBalance extends Document { user: Types.ObjectId; @@ -6,7 +7,7 @@ export interface IBalance extends Document { // Automatic refill settings autoRefillEnabled: boolean; refillIntervalValue: number; - refillIntervalUnit: 'seconds' | 'minutes' | 'hours' | 'days' | 'weeks' | 'months'; + refillIntervalUnit: RefillIntervalUnit; lastRefill: Date; refillAmount: number; tenantId?: string; @@ -18,7 +19,7 @@ export interface IBalanceUpdate { tokenCredits?: number; autoRefillEnabled?: boolean; refillIntervalValue?: number; - refillIntervalUnit?: string; + refillIntervalUnit?: RefillIntervalUnit; refillAmount?: number; lastRefill?: Date; } diff --git a/packages/data-schemas/src/types/user.ts b/packages/data-schemas/src/types/user.ts index 86d382a711..b9296a4be5 100644 --- a/packages/data-schemas/src/types/user.ts +++ b/packages/data-schemas/src/types/user.ts @@ -1,5 +1,5 @@ +import type { RefillIntervalUnit, TUserFavorite } from 'librechat-data-provider'; import type { Document, Types } from 'mongoose'; -import type { TUserFavorite } from 'librechat-data-provider'; import { CursorPaginationParams } from '~/common'; export interface IUser extends Document { @@ -75,7 +75,7 @@ export interface BalanceConfig { startBalance?: number; autoRefillEnabled?: boolean; refillIntervalValue?: number; - refillIntervalUnit?: string; + refillIntervalUnit?: RefillIntervalUnit; refillAmount?: number; }