mirror of
https://github.com/danny-avila/LibreChat.git
synced 2026-08-04 14:57:42 +00:00
⏱️ 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:
parent
5efbcb8b93
commit
ddf5879ccd
12 changed files with 138 additions and 149 deletions
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
49
packages/data-provider/src/balance.spec.ts
Normal file
49
packages/data-provider/src/balance.spec.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
46
packages/data-provider/src/balance.ts
Normal file
46
packages/data-provider/src/balance.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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),
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -1,6 +1,7 @@
|
|||
/* config */
|
||||
export * from './azure';
|
||||
export * from './bedrock';
|
||||
export * from './balance';
|
||||
export * from './config';
|
||||
export * from './file-config';
|
||||
/* messages */
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
};
|
||||
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
};
|
||||
} = {
|
||||
|
|
|
|||
|
|
@ -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: {
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue