diff --git a/backend/src/PnvPanel.Api/Telegram/PnvBotUpdateHandler.cs b/backend/src/PnvPanel.Api/Telegram/PnvBotUpdateHandler.cs index 99560be..84b328e 100644 --- a/backend/src/PnvPanel.Api/Telegram/PnvBotUpdateHandler.cs +++ b/backend/src/PnvPanel.Api/Telegram/PnvBotUpdateHandler.cs @@ -2,6 +2,8 @@ using Microsoft.Extensions.Options; using PnvPanel.Application.Admin.Activation; using PnvPanel.Application.Admin.Billing; using PnvPanel.Application.Admin.Support; +using PnvPanel.Application.Billing; +using PnvPanel.Application.Billing.GetMyBillingStatus; using PnvPanel.Application.Common.Interfaces; using PnvPanel.Application.Common.Messaging; using PnvPanel.Application.Configs.GetConfigLink; @@ -136,6 +138,15 @@ public sealed class PnvBotUpdateHandler( cancellationToken ); break; + case "/billing": + await HandleBillingStatusAsync( + botClient, + services, + chatId, + fromId.Value, + cancellationToken + ); + break; case "/unlink": await HandleUnlinkAsync( botClient, @@ -230,6 +241,36 @@ public sealed class PnvBotUpdateHandler( return; } + if (data == "menu:billing") + { + if (!await TrySetCurrentUserAsync(services, fromId, cancellationToken)) + { + await botClient.AnswerCallbackQuery( + callback.Id, + "Telegram не привязан.", + cancellationToken: cancellationToken + ); + return; + } + + await botClient.AnswerCallbackQuery(callback.Id, cancellationToken: cancellationToken); + + var (billingText, billingKeyboard) = await BuildBillingStatusMenuAsync( + services, + cancellationToken + ); + if (callback.Message is not null) + await botClient.EditMessageText( + chatId.Value, + callback.Message.Id, + billingText, + replyMarkup: billingKeyboard, + cancellationToken: cancellationToken + ); + + return; + } + if (data == "menu:back") { await botClient.AnswerCallbackQuery(callback.Id, cancellationToken: cancellationToken); @@ -241,7 +282,12 @@ public sealed class PnvBotUpdateHandler( fromId, cancellationToken ); - var (menuText, menuKeyboard) = BuildMainMenu(isLinked: linkedUserId is not null); + var linkedProfile = + linkedUserId is { } uid ? await identityService.GetProfileAsync(uid, cancellationToken) : null; + var (menuText, menuKeyboard) = BuildMainMenu( + isLinked: linkedUserId is not null, + billingEnabled: linkedProfile?.BillingEnabled ?? false + ); await botClient.EditMessageText( chatId.Value, callback.Message.Id, @@ -706,6 +752,74 @@ public sealed class PnvBotUpdateHandler( return (text, new InlineKeyboardMarkup(rows)); } + private async Task HandleBillingStatusAsync( + ITelegramBotClient botClient, + IServiceProvider services, + long chatId, + long fromId, + CancellationToken cancellationToken + ) + { + if (!await TrySetCurrentUserAsync(services, fromId, cancellationToken)) + { + await botClient.SendMessage(chatId, NotLinkedMessage, cancellationToken: cancellationToken); + return; + } + + var (text, keyboard) = await BuildBillingStatusMenuAsync(services, cancellationToken); + await botClient.SendMessage( + chatId, + text, + replyMarkup: keyboard, + cancellationToken: cancellationToken + ); + } + + /// До какого числа оплачено + остаток в человекочитаемом виде (дни, либо часы/минуты, + /// если меньше суток) — то же форматирование, что и на сайте (PaidUntilBadge). + private static async Task<(string Text, InlineKeyboardMarkup Keyboard)> BuildBillingStatusMenuAsync( + IServiceProvider services, + CancellationToken cancellationToken + ) + { + var sender = services.GetRequiredService(); + var result = await sender.Send(new GetMyBillingStatusQuery(), cancellationToken); + + if (!result.IsSuccess) + return ($"⚠️ {result.Error.Message}", BackToMenuKeyboard()); + + var status = result.Value; + if (!status.BillingEnabled) + return ("Биллинг не применяется к вашей роли.", BackToMenuKeyboard()); + + if (status.PaidUntil is not { } paidUntil) + return ("Оплата ещё не производилась.", BackToMenuKeyboard()); + + var text = + $"💳 Оплачено до {paidUntil:dd.MM.yyyy HH:mm}\nОсталось: {FormatRemaining(paidUntil)}" + + (status.Suspended ? "\n\n⛔ Конфиги приостановлены за неуплату." : ""); + + return (text, BackToMenuKeyboard()); + } + + private static string FormatRemaining(DateTimeOffset paidUntil) + { + var remaining = paidUntil - DateTimeOffset.UtcNow; + if (remaining <= TimeSpan.Zero) + return "истекло"; + + if (remaining < TimeSpan.FromDays(1)) + { + var totalMinutes = (int)Math.Ceiling(remaining.TotalMinutes); + var hours = totalMinutes / 60; + var minutes = totalMinutes % 60; + return hours > 0 ? $"{hours} ч {minutes} мин" : $"{minutes} мин"; + } + + var days = (int)Math.Ceiling(remaining.TotalDays); + return $"{days} дн."; + } + private static InlineKeyboardButton[] BackToMenuRow() => new[] { InlineKeyboardButton.WithCallbackData("🔙 В меню", "menu:back") }; @@ -811,8 +925,13 @@ public sealed class PnvBotUpdateHandler( fromId, cancellationToken ); + var profile = + userId is { } uid ? await identityService.GetProfileAsync(uid, cancellationToken) : null; - var (text, keyboard) = BuildMainMenu(isLinked: userId is not null); + var (text, keyboard) = BuildMainMenu( + isLinked: userId is not null, + billingEnabled: profile?.BillingEnabled ?? false + ); await botClient.SendMessage( chatId, text, @@ -822,8 +941,9 @@ public sealed class PnvBotUpdateHandler( } /// Привязанному аккаунту — кнопки-действия вместо текстовых команд; непривязанному — - /// только регистрация (остальное ему всё равно недоступно). Кнопка на сайт — если задан PublicSiteUrl. - private (string Text, InlineKeyboardMarkup Keyboard) BuildMainMenu(bool isLinked) + /// только регистрация (остальное ему всё равно недоступно). Кнопка на сайт — если задан PublicSiteUrl. + /// billingEnabled — показать «Статус оплаты» только для billing-ролей (см. AppRole.BillingEnabled). + private (string Text, InlineKeyboardMarkup Keyboard) BuildMainMenu(bool isLinked, bool billingEnabled = false) { const string text = "Привет! Это бот PnvPanel.\n\n" @@ -835,6 +955,13 @@ public sealed class PnvBotUpdateHandler( rows.Add( new[] { InlineKeyboardButton.WithCallbackData("📋 Мои конфиги", "menu:configs") } ); + if (billingEnabled) + rows.Add( + new[] + { + InlineKeyboardButton.WithCallbackData("💳 Статус оплаты", "menu:billing"), + } + ); rows.Add( new[] { diff --git a/docs/telegram-bot.md b/docs/telegram-bot.md index 628da88..d6e7fc8 100644 --- a/docs/telegram-bot.md +++ b/docs/telegram-bot.md @@ -221,6 +221,14 @@ Telegram ──updates──► TelegramBotHostedService → PnvBotUpdateHandl оплаты и уведомление о приостановке конфигов при просрочке — оба через `NotifyUserAsync`, без инлайн-кнопок. +**Статус оплаты по кнопке**: для пользователей с billing-ролью (`AppRole.BillingEnabled`) в главном +меню бота появляется «💳 Статус оплаты» (`menu:billing`, либо команда `/billing`) — показывает дату, +до которой оплачено, и остаток в человекочитаемом виде: дни, если их ≥ 1 (`N дн.`), иначе часы и +минуты (`N ч M мин` / `M мин`) — то же форматирование, что и бейдж на сайте +(`PaidUntilBadge`/`formatRemaining`, см. [domain-model.md](domain-model.md#billing--подписка-по-сроку)). +Кнопка скрыта для не-billing ролей — `BuildMainMenu` подставляет `billingEnabled` из +`CurrentUserProfile` при каждом рендере меню. + ## Команды и клавиатуры | Команда / кнопка | Действие | Требует привязки | @@ -229,6 +237,7 @@ Telegram ──updates──► TelegramBotHostedService → PnvBotUpdateHandl | `/start link_` | Привязка аккаунта по токену | нет | | `/start login_` | Подтверждение passwordless-входа (deep-link с сайта) | да | | `/configs`, «📋 Мои конфиги» (`menu:configs`) | Список конфигов, кнопка `🔗 {Label}` на каждый активный + «🔙 В меню» | да | +| `/billing`, «💳 Статус оплаты» (`menu:billing`) | Дата, до которой оплачено, и остаток (дни/часы/минуты) — только для billing-ролей | да | | `/unlink`, «🔓 Отвязать Telegram» (`menu:unlink`) | Отвязать Telegram от аккаунта | да | | «🔙 В меню» (`menu:back`) | Вернуться из списка конфигов к главному меню (edit-in-place) | нет | | «🌐 Сайт панели» | Открыть сайт (`InlineKeyboardButton.WithUrl`, только если задан `Telegram__PublicSiteUrl`) | нет | diff --git a/frontend/src/features/billing/PaidUntilBadge.tsx b/frontend/src/features/billing/PaidUntilBadge.tsx index 87a0a53..2684e0b 100644 --- a/frontend/src/features/billing/PaidUntilBadge.tsx +++ b/frontend/src/features/billing/PaidUntilBadge.tsx @@ -1,15 +1,10 @@ import { useTranslation } from 'react-i18next' import { Badge } from '@/shared/ui/badge' +import { formatRemaining, getDaysRemaining } from './remaining' const RED_THRESHOLD_DAYS = 3 const YELLOW_THRESHOLD_DAYS = 7 -/** Дней до paidUntil, округление вверх — «меньше суток» всё равно считается как 1 день, а не 0. */ -function daysRemaining(paidUntil: string): number { - const diffMs = new Date(paidUntil).getTime() - Date.now() - return Math.ceil(diffMs / (24 * 60 * 60 * 1000)) -} - /** Цветовая индикация оплаченного периода: зелёный — обычный запас, жёлтый — ≤7 дней, красный — * ≤3 дней или уже истекло. Общий компонент для дашборда пользователя и списка пользователей в админке. */ export function PaidUntilBadge({ paidUntil }: { paidUntil: string | null }) { @@ -17,16 +12,12 @@ export function PaidUntilBadge({ paidUntil }: { paidUntil: string | null }) { if (!paidUntil) return {t('billing.neverPaidShort')} - const days = daysRemaining(paidUntil) + const days = getDaysRemaining(paidUntil) const variant = days <= RED_THRESHOLD_DAYS ? 'destructive' : days <= YELLOW_THRESHOLD_DAYS ? 'warning' : 'success' - const label = - days <= 0 - ? t('billing.expiredShort') - : t('billing.daysRemaining', { count: days }) return ( - - {label} + + {formatRemaining(paidUntil, t)} ) } diff --git a/frontend/src/features/billing/remaining.ts b/frontend/src/features/billing/remaining.ts new file mode 100644 index 0000000..e6f624f --- /dev/null +++ b/frontend/src/features/billing/remaining.ts @@ -0,0 +1,27 @@ +const DAY_MS = 24 * 60 * 60 * 1000 + +type TranslateFn = (key: string, options?: Record) => string + +/** Дней до paidUntil (дробное число, для цветовых порогов — не округлять до отображения). */ +export function getDaysRemaining(paidUntil: string): number { + return (new Date(paidUntil).getTime() - Date.now()) / DAY_MS +} + +/** Человекочитаемый остаток: "N дн." если ≥ суток, иначе "N ч M мин"/"M мин", "истекло" если уже + * прошло. То же форматирование, что в боте (см. FormatRemaining в PnvBotUpdateHandler.cs). */ +export function formatRemaining(paidUntil: string, t: TranslateFn): string { + const diffMs = new Date(paidUntil).getTime() - Date.now() + if (diffMs <= 0) return t('billing.expiredShort') + + if (diffMs < DAY_MS) { + const totalMinutes = Math.ceil(diffMs / 60000) + const hours = Math.floor(totalMinutes / 60) + const minutes = totalMinutes % 60 + return hours > 0 + ? t('billing.hoursMinutesRemaining', { hours, minutes }) + : t('billing.minutesRemaining', { count: minutes }) + } + + const days = Math.ceil(diffMs / DAY_MS) + return t('billing.daysRemaining', { count: days }) +} diff --git a/frontend/src/routes/billing.tsx b/frontend/src/routes/billing.tsx index 65ff8ed..cd947db 100644 --- a/frontend/src/routes/billing.tsx +++ b/frontend/src/routes/billing.tsx @@ -7,6 +7,7 @@ import { Card, CardContent, CardHeader, CardTitle } from '@/shared/ui/card' import { Badge } from '@/shared/ui/badge' import { Button } from '@/shared/ui/button' import { getMyBillingStatus } from '@/features/billing/api' +import { PaidUntilBadge } from '@/features/billing/PaidUntilBadge' import { PaymentRequestPanel } from '@/features/billing/PaymentRequestPanel' export const Route = createFileRoute('/billing')({ component: BillingPage }) @@ -56,12 +57,13 @@ function BillingContent() { {t('billing.status')} {data.suspended && {t('billing.suspended')}} - +

{data.paidUntil ? t('billing.paidUntil', { date: new Date(data.paidUntil).toLocaleDateString() }) : t('billing.neverPaid')}

+ {data.paidUntil && }
diff --git a/frontend/src/shared/lib/i18n.ts b/frontend/src/shared/lib/i18n.ts index ec58697..fa5f8f7 100644 --- a/frontend/src/shared/lib/i18n.ts +++ b/frontend/src/shared/lib/i18n.ts @@ -112,6 +112,8 @@ const resources = { neverPaidShort: 'Не оплачено', expiredShort: 'Истекло', daysRemaining: '{{count}} дн.', + hoursMinutesRemaining: '{{hours}} ч {{minutes}} мин', + minutesRemaining: '{{count}} мин', newRequest: 'Оформить оплату', period: 'Период', periods: { @@ -626,6 +628,8 @@ const resources = { neverPaidShort: 'Not paid', expiredShort: 'Expired', daysRemaining: '{{count}}d', + hoursMinutesRemaining: '{{hours}}h {{minutes}}m', + minutesRemaining: '{{count}}m', newRequest: 'Set up payment', period: 'Period', periods: {