diff --git a/apps/api/src/services/dashboard-stats.test.ts b/apps/api/src/services/dashboard-stats.test.ts new file mode 100644 index 0000000..ffb2d13 --- /dev/null +++ b/apps/api/src/services/dashboard-stats.test.ts @@ -0,0 +1,94 @@ +import { describe, expect, it } from 'vitest' + +import { computeMinRunwayDays } from './dashboard-stats.js' + +describe('computeMinRunwayDays', () => { + const now = new Date('2026-07-20T12:00:00Z') + const accounts = [{ id: 'acc-1', balanceApi: 0 }] + const emptyExtra = { payments: [], balanceLedger: [] } + + it('prepaid: берёт ближайший paidUntil, игнорирует нулевой баланс', () => { + const days = computeMinRunwayDays( + [ + { + id: 'v1', + status: 'active', + providerAccountId: 'acc-1', + tariffType: 'monthly', + monthlyRate: 500, + paidUntil: '2026-08-21', + }, + { + id: 'v2', + status: 'active', + providerAccountId: 'acc-1', + tariffType: 'monthly', + monthlyRate: 300, + paidUntil: '2026-09-01', + }, + ], + { providerAccounts: accounts, ...emptyExtra }, + now, + ) + // 2026-07-20 → 2026-08-21 = 32 дня + expect(days).toBe(32) + }) + + it('просроченный VPS → запас 0', () => { + const days = computeMinRunwayDays( + [ + { + id: 'v1', + status: 'active', + providerAccountId: 'acc-1', + tariffType: 'monthly', + monthlyRate: 100, + paidUntil: '2026-07-01', + }, + { + id: 'v2', + status: 'active', + providerAccountId: 'acc-1', + tariffType: 'monthly', + monthlyRate: 100, + paidUntil: '2026-09-01', + }, + ], + { providerAccounts: accounts, ...emptyExtra }, + now, + ) + expect(days).toBe(0) + }) + + it('daily: запас из баланса / burn, а не из нулевого paidUntil-логики', () => { + const days = computeMinRunwayDays( + [ + { + id: 'v1', + status: 'active', + providerAccountId: 'acc-1', + tariffType: 'daily', + dailyRate: 10, + paidUntil: '2026-07-21', + }, + ], + { + providerAccounts: [{ id: 'acc-1', balanceApi: 100, billingMode: 'daily' }], + ...emptyExtra, + }, + now, + ) + // 100 / 10 = 10 дней покрытия + expect(days).toBe(10) + }) + + it('без активных с датой → null', () => { + expect( + computeMinRunwayDays( + [{ id: 'v1', status: 'stopped', paidUntil: '2026-08-01' }], + { providerAccounts: accounts, ...emptyExtra }, + now, + ), + ).toBeNull() + }) +}) diff --git a/apps/api/src/services/dashboard-stats.ts b/apps/api/src/services/dashboard-stats.ts index ec5de1e..209873a 100644 --- a/apps/api/src/services/dashboard-stats.ts +++ b/apps/api/src/services/dashboard-stats.ts @@ -2,6 +2,12 @@ import { getSnapshot } from '@cfdm/db/repositories/snapshot' import { countExpiringWithin7Days, countInventoryIssues } from '@cfdm/shared/utils/inventory-health' import { accountBalanceApi } from '@cfdm/shared/utils/account-balance' import { isSyncApiType } from '@cfdm/shared/contracts/provider' +import { + getPaidUntilDate, + type PaidUntilAccount, + type PaidUntilContext, + type PaidUntilVps, +} from '@cfdm/shared/utils/paid-until' const STALE_SYNC_HOURS = 48 @@ -28,6 +34,32 @@ function lastOkSyncAt(accountId: string, syncLog: { accountId: string; status: s return best } +/** + * Минимальный остаток дней до оплаты среди активных VPS. + * Prepaid: по `paidUntil`; daily / «завтра»: через `getPaidUntilDate` (баланс). + * Не путать с balance/burn — у prepaid баланс часто ≈0 при уже оплаченном периоде. + */ +export function computeMinRunwayDays( + vps: PaidUntilVps[], + ctx: Omit & { providerAccounts: PaidUntilAccount[] }, + now = new Date(), +): number | null { + const todayStart = new Date(now.getFullYear(), now.getMonth(), now.getDate()) + const fullCtx: PaidUntilContext = { ...ctx, vps, now } + let minDays: number | null = null + + for (const item of vps) { + if (item.status !== 'active') continue + const until = getPaidUntilDate(item, fullCtx) + if (until == null) continue + const days = Math.floor((until.getTime() - todayStart.getTime()) / (24 * 60 * 60 * 1000)) + const remaining = Math.max(0, days) + if (minDays == null || remaining < minDays) minDays = remaining + } + + return minDays +} + export interface DashboardStats { activeVpsCount: number totalVpsCount: number @@ -54,22 +86,12 @@ export function computeDashboardStats(): DashboardStats { 0, ) - const burnByAccount = new Map() - for (const v of activeVps) { - const burn = vpsBurnRate(v) - const accountId = v.providerAccountId - if (!accountId) continue - burnByAccount.set(accountId, (burnByAccount.get(accountId) ?? 0) + burn) - } - - let minRunwayDays: number | null = null - for (const account of snap.providerAccounts) { - const balance = Number(account.balanceApi ?? 0) - const burn = burnByAccount.get(account.id) ?? 0 - if (balance <= 0 || burn <= 0) continue - const days = Math.floor((balance / burn) * 30) - if (minRunwayDays == null || days < minRunwayDays) minRunwayDays = days + const paidUntilCtx = { + providerAccounts: snap.providerAccounts, + payments: snap.payments, + balanceLedger: snap.balanceLedger, } + const minRunwayDays = computeMinRunwayDays(snap.vps, paidUntilCtx, now) const expiringWithin7Days = countExpiringWithin7Days( { diff --git a/apps/web/src/components/layout/system-monitor-popover.tsx b/apps/web/src/components/layout/system-monitor-popover.tsx index 769b853..174cb7c 100644 --- a/apps/web/src/components/layout/system-monitor-popover.tsx +++ b/apps/web/src/components/layout/system-monitor-popover.tsx @@ -142,7 +142,7 @@ export function SystemMonitorPopover() { }, { id: 'runway', - label: 'Runway', + label: 'Запас дней', value: runwayDays != null ? String(runwayDays) : '—', unit: runwayDays != null ? 'дн' : '', percent: diff --git a/apps/web/src/routes/_auth/dashboard.tsx b/apps/web/src/routes/_auth/dashboard.tsx index 1545076..cd3e07c 100644 --- a/apps/web/src/routes/_auth/dashboard.tsx +++ b/apps/web/src/routes/_auth/dashboard.tsx @@ -307,7 +307,7 @@ function DashboardPage() { }, { id: 'runway', - label: 'Runway', + label: 'Запас дней', value: runwayDays != null ? `${runwayDays} дн` : '—', icon: , iconClassName: runwayLow ? 'text-warning' : 'text-muted-foreground',