⏱️ fix: Align Auto-Refill Next Date (#12980)

* fix: Align auto-refill next date

* style: Fix auto-refill lint formatting

* refactor: Share auto-refill eligibility date

* refactor: Consolidate refill interval units

* fix: Guard malformed refill interval units

* fix: Preserve refill unit fallback label
This commit is contained in:
Danny Avila 2026-05-06 21:40:18 -04:00 committed by GitHub
parent 5efbcb8b93
commit ddf5879ccd
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
12 changed files with 138 additions and 149 deletions

View file

@ -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<TBalanceResponse['lastRefill']>;
refillAmount: number;
refillIntervalUnit: RefillIntervalUnit;
refillIntervalValue: number;
}
const AutoRefillSettings: React.FC<AutoRefillSettingsProps> = ({
@ -106,15 +27,11 @@ const AutoRefillSettings: React.FC<AutoRefillSettingsProps> = ({
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<AutoRefillSettingsProps> = ({
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<AutoRefillSettingsProps> = ({
</span>
</div>
<div className="flex items-center justify-between">
{/* Left Section: Label */}
<div className="flex items-center space-x-2">
<Label className="font-light">{localize('com_nav_balance_next_refill')}</Label>
<InfoHoverCard side={ESide.Bottom} text={localize('com_nav_balance_next_refill_info')} />
</div>
{/* Right Section: tokenCredits Value */}
<span className="text-sm font-medium text-gray-800 dark:text-gray-200" role="note">
{nextRefill ? nextRefill.toLocaleString() : '-'}
{refillEligibilityDate ? refillEligibilityDate.toLocaleString() : '-'}
</span>
</div>
</div>

View file

@ -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<BalanceRecord | null>;
}
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',

View file

@ -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;
}

View file

@ -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);
});
});

View file

@ -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;
}
}
}

View file

@ -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),
});

View file

@ -1,6 +1,7 @@
/* config */
export * from './azure';
export * from './bedrock';
export * from './balance';
export * from './config';
export * from './file-config';
/* messages */

View file

@ -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;
};

View file

@ -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;
};
} = {

View file

@ -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<t.IBalance>({
@ -24,7 +25,7 @@ const balanceSchema = new Schema<t.IBalance>({
},
refillIntervalUnit: {
type: String,
enum: ['seconds', 'minutes', 'hours', 'days', 'weeks', 'months'],
enum: REFILL_INTERVAL_UNITS,
default: 'days',
},
lastRefill: {

View file

@ -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;
}

View file

@ -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;
}