Implement billing status feature in Telegram bot
- Added a new command `/billing` and a corresponding menu option for users with billing roles to check their payment status. - Implemented `HandleBillingStatusAsync` method to retrieve and display billing information, including the payment expiration date and remaining time in a user-friendly format. - Updated the main menu to conditionally show the billing status option based on the user's role. - Enhanced the `PaidUntilBadge` component to format and display the remaining time until the next payment in both days and hours/minutes. - Updated documentation to reflect the new billing status feature and its usage in the Telegram bot.
This commit is contained in:
@@ -2,6 +2,8 @@ using Microsoft.Extensions.Options;
|
|||||||
using PnvPanel.Application.Admin.Activation;
|
using PnvPanel.Application.Admin.Activation;
|
||||||
using PnvPanel.Application.Admin.Billing;
|
using PnvPanel.Application.Admin.Billing;
|
||||||
using PnvPanel.Application.Admin.Support;
|
using PnvPanel.Application.Admin.Support;
|
||||||
|
using PnvPanel.Application.Billing;
|
||||||
|
using PnvPanel.Application.Billing.GetMyBillingStatus;
|
||||||
using PnvPanel.Application.Common.Interfaces;
|
using PnvPanel.Application.Common.Interfaces;
|
||||||
using PnvPanel.Application.Common.Messaging;
|
using PnvPanel.Application.Common.Messaging;
|
||||||
using PnvPanel.Application.Configs.GetConfigLink;
|
using PnvPanel.Application.Configs.GetConfigLink;
|
||||||
@@ -136,6 +138,15 @@ public sealed class PnvBotUpdateHandler(
|
|||||||
cancellationToken
|
cancellationToken
|
||||||
);
|
);
|
||||||
break;
|
break;
|
||||||
|
case "/billing":
|
||||||
|
await HandleBillingStatusAsync(
|
||||||
|
botClient,
|
||||||
|
services,
|
||||||
|
chatId,
|
||||||
|
fromId.Value,
|
||||||
|
cancellationToken
|
||||||
|
);
|
||||||
|
break;
|
||||||
case "/unlink":
|
case "/unlink":
|
||||||
await HandleUnlinkAsync(
|
await HandleUnlinkAsync(
|
||||||
botClient,
|
botClient,
|
||||||
@@ -230,6 +241,36 @@ public sealed class PnvBotUpdateHandler(
|
|||||||
return;
|
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")
|
if (data == "menu:back")
|
||||||
{
|
{
|
||||||
await botClient.AnswerCallbackQuery(callback.Id, cancellationToken: cancellationToken);
|
await botClient.AnswerCallbackQuery(callback.Id, cancellationToken: cancellationToken);
|
||||||
@@ -241,7 +282,12 @@ public sealed class PnvBotUpdateHandler(
|
|||||||
fromId,
|
fromId,
|
||||||
cancellationToken
|
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(
|
await botClient.EditMessageText(
|
||||||
chatId.Value,
|
chatId.Value,
|
||||||
callback.Message.Id,
|
callback.Message.Id,
|
||||||
@@ -706,6 +752,74 @@ public sealed class PnvBotUpdateHandler(
|
|||||||
return (text, new InlineKeyboardMarkup(rows));
|
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
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>До какого числа оплачено + остаток в человекочитаемом виде (дни, либо часы/минуты,
|
||||||
|
/// если меньше суток) — то же форматирование, что и на сайте (PaidUntilBadge).</summary>
|
||||||
|
private static async Task<(string Text, InlineKeyboardMarkup Keyboard)> BuildBillingStatusMenuAsync(
|
||||||
|
IServiceProvider services,
|
||||||
|
CancellationToken cancellationToken
|
||||||
|
)
|
||||||
|
{
|
||||||
|
var sender = services.GetRequiredService<ISender>();
|
||||||
|
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() =>
|
private static InlineKeyboardButton[] BackToMenuRow() =>
|
||||||
new[] { InlineKeyboardButton.WithCallbackData("🔙 В меню", "menu:back") };
|
new[] { InlineKeyboardButton.WithCallbackData("🔙 В меню", "menu:back") };
|
||||||
|
|
||||||
@@ -811,8 +925,13 @@ public sealed class PnvBotUpdateHandler(
|
|||||||
fromId,
|
fromId,
|
||||||
cancellationToken
|
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(
|
await botClient.SendMessage(
|
||||||
chatId,
|
chatId,
|
||||||
text,
|
text,
|
||||||
@@ -822,8 +941,9 @@ public sealed class PnvBotUpdateHandler(
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>Привязанному аккаунту — кнопки-действия вместо текстовых команд; непривязанному —
|
/// <summary>Привязанному аккаунту — кнопки-действия вместо текстовых команд; непривязанному —
|
||||||
/// только регистрация (остальное ему всё равно недоступно). Кнопка на сайт — если задан PublicSiteUrl.</summary>
|
/// только регистрация (остальное ему всё равно недоступно). Кнопка на сайт — если задан PublicSiteUrl.
|
||||||
private (string Text, InlineKeyboardMarkup Keyboard) BuildMainMenu(bool isLinked)
|
/// billingEnabled — показать «Статус оплаты» только для billing-ролей (см. AppRole.BillingEnabled).</summary>
|
||||||
|
private (string Text, InlineKeyboardMarkup Keyboard) BuildMainMenu(bool isLinked, bool billingEnabled = false)
|
||||||
{
|
{
|
||||||
const string text =
|
const string text =
|
||||||
"Привет! Это бот PnvPanel.\n\n"
|
"Привет! Это бот PnvPanel.\n\n"
|
||||||
@@ -835,6 +955,13 @@ public sealed class PnvBotUpdateHandler(
|
|||||||
rows.Add(
|
rows.Add(
|
||||||
new[] { InlineKeyboardButton.WithCallbackData("📋 Мои конфиги", "menu:configs") }
|
new[] { InlineKeyboardButton.WithCallbackData("📋 Мои конфиги", "menu:configs") }
|
||||||
);
|
);
|
||||||
|
if (billingEnabled)
|
||||||
|
rows.Add(
|
||||||
|
new[]
|
||||||
|
{
|
||||||
|
InlineKeyboardButton.WithCallbackData("💳 Статус оплаты", "menu:billing"),
|
||||||
|
}
|
||||||
|
);
|
||||||
rows.Add(
|
rows.Add(
|
||||||
new[]
|
new[]
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -221,6 +221,14 @@ Telegram ──updates──► TelegramBotHostedService → PnvBotUpdateHandl
|
|||||||
оплаты и уведомление о приостановке конфигов при просрочке — оба через `NotifyUserAsync`, без
|
оплаты и уведомление о приостановке конфигов при просрочке — оба через `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_<token>` | Привязка аккаунта по токену | нет |
|
| `/start link_<token>` | Привязка аккаунта по токену | нет |
|
||||||
| `/start login_<requestId>` | Подтверждение passwordless-входа (deep-link с сайта) | да |
|
| `/start login_<requestId>` | Подтверждение passwordless-входа (deep-link с сайта) | да |
|
||||||
| `/configs`, «📋 Мои конфиги» (`menu:configs`) | Список конфигов, кнопка `🔗 {Label}` на каждый активный + «🔙 В меню» | да |
|
| `/configs`, «📋 Мои конфиги» (`menu:configs`) | Список конфигов, кнопка `🔗 {Label}` на каждый активный + «🔙 В меню» | да |
|
||||||
|
| `/billing`, «💳 Статус оплаты» (`menu:billing`) | Дата, до которой оплачено, и остаток (дни/часы/минуты) — только для billing-ролей | да |
|
||||||
| `/unlink`, «🔓 Отвязать Telegram» (`menu:unlink`) | Отвязать Telegram от аккаунта | да |
|
| `/unlink`, «🔓 Отвязать Telegram» (`menu:unlink`) | Отвязать Telegram от аккаунта | да |
|
||||||
| «🔙 В меню» (`menu:back`) | Вернуться из списка конфигов к главному меню (edit-in-place) | нет |
|
| «🔙 В меню» (`menu:back`) | Вернуться из списка конфигов к главному меню (edit-in-place) | нет |
|
||||||
| «🌐 Сайт панели» | Открыть сайт (`InlineKeyboardButton.WithUrl`, только если задан `Telegram__PublicSiteUrl`) | нет |
|
| «🌐 Сайт панели» | Открыть сайт (`InlineKeyboardButton.WithUrl`, только если задан `Telegram__PublicSiteUrl`) | нет |
|
||||||
|
|||||||
@@ -1,15 +1,10 @@
|
|||||||
import { useTranslation } from 'react-i18next'
|
import { useTranslation } from 'react-i18next'
|
||||||
import { Badge } from '@/shared/ui/badge'
|
import { Badge } from '@/shared/ui/badge'
|
||||||
|
import { formatRemaining, getDaysRemaining } from './remaining'
|
||||||
|
|
||||||
const RED_THRESHOLD_DAYS = 3
|
const RED_THRESHOLD_DAYS = 3
|
||||||
const YELLOW_THRESHOLD_DAYS = 7
|
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 дней, красный —
|
/** Цветовая индикация оплаченного периода: зелёный — обычный запас, жёлтый — ≤7 дней, красный —
|
||||||
* ≤3 дней или уже истекло. Общий компонент для дашборда пользователя и списка пользователей в админке. */
|
* ≤3 дней или уже истекло. Общий компонент для дашборда пользователя и списка пользователей в админке. */
|
||||||
export function PaidUntilBadge({ paidUntil }: { paidUntil: string | null }) {
|
export function PaidUntilBadge({ paidUntil }: { paidUntil: string | null }) {
|
||||||
@@ -17,16 +12,12 @@ export function PaidUntilBadge({ paidUntil }: { paidUntil: string | null }) {
|
|||||||
|
|
||||||
if (!paidUntil) return <Badge variant="destructive">{t('billing.neverPaidShort')}</Badge>
|
if (!paidUntil) return <Badge variant="destructive">{t('billing.neverPaidShort')}</Badge>
|
||||||
|
|
||||||
const days = daysRemaining(paidUntil)
|
const days = getDaysRemaining(paidUntil)
|
||||||
const variant = days <= RED_THRESHOLD_DAYS ? 'destructive' : days <= YELLOW_THRESHOLD_DAYS ? 'warning' : 'success'
|
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 (
|
return (
|
||||||
<Badge variant={variant} title={new Date(paidUntil).toLocaleDateString()}>
|
<Badge variant={variant} title={new Date(paidUntil).toLocaleString()}>
|
||||||
{label}
|
{formatRemaining(paidUntil, t)}
|
||||||
</Badge>
|
</Badge>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,27 @@
|
|||||||
|
const DAY_MS = 24 * 60 * 60 * 1000
|
||||||
|
|
||||||
|
type TranslateFn = (key: string, options?: Record<string, unknown>) => 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 })
|
||||||
|
}
|
||||||
@@ -7,6 +7,7 @@ import { Card, CardContent, CardHeader, CardTitle } from '@/shared/ui/card'
|
|||||||
import { Badge } from '@/shared/ui/badge'
|
import { Badge } from '@/shared/ui/badge'
|
||||||
import { Button } from '@/shared/ui/button'
|
import { Button } from '@/shared/ui/button'
|
||||||
import { getMyBillingStatus } from '@/features/billing/api'
|
import { getMyBillingStatus } from '@/features/billing/api'
|
||||||
|
import { PaidUntilBadge } from '@/features/billing/PaidUntilBadge'
|
||||||
import { PaymentRequestPanel } from '@/features/billing/PaymentRequestPanel'
|
import { PaymentRequestPanel } from '@/features/billing/PaymentRequestPanel'
|
||||||
|
|
||||||
export const Route = createFileRoute('/billing')({ component: BillingPage })
|
export const Route = createFileRoute('/billing')({ component: BillingPage })
|
||||||
@@ -56,12 +57,13 @@ function BillingContent() {
|
|||||||
<CardTitle className="text-base">{t('billing.status')}</CardTitle>
|
<CardTitle className="text-base">{t('billing.status')}</CardTitle>
|
||||||
{data.suspended && <Badge variant="destructive">{t('billing.suspended')}</Badge>}
|
{data.suspended && <Badge variant="destructive">{t('billing.suspended')}</Badge>}
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent>
|
<CardContent className="flex flex-col gap-2">
|
||||||
<p className="text-sm text-muted-foreground">
|
<p className="text-sm text-muted-foreground">
|
||||||
{data.paidUntil
|
{data.paidUntil
|
||||||
? t('billing.paidUntil', { date: new Date(data.paidUntil).toLocaleDateString() })
|
? t('billing.paidUntil', { date: new Date(data.paidUntil).toLocaleDateString() })
|
||||||
: t('billing.neverPaid')}
|
: t('billing.neverPaid')}
|
||||||
</p>
|
</p>
|
||||||
|
{data.paidUntil && <PaidUntilBadge paidUntil={data.paidUntil} />}
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
|
|||||||
@@ -112,6 +112,8 @@ const resources = {
|
|||||||
neverPaidShort: 'Не оплачено',
|
neverPaidShort: 'Не оплачено',
|
||||||
expiredShort: 'Истекло',
|
expiredShort: 'Истекло',
|
||||||
daysRemaining: '{{count}} дн.',
|
daysRemaining: '{{count}} дн.',
|
||||||
|
hoursMinutesRemaining: '{{hours}} ч {{minutes}} мин',
|
||||||
|
minutesRemaining: '{{count}} мин',
|
||||||
newRequest: 'Оформить оплату',
|
newRequest: 'Оформить оплату',
|
||||||
period: 'Период',
|
period: 'Период',
|
||||||
periods: {
|
periods: {
|
||||||
@@ -626,6 +628,8 @@ const resources = {
|
|||||||
neverPaidShort: 'Not paid',
|
neverPaidShort: 'Not paid',
|
||||||
expiredShort: 'Expired',
|
expiredShort: 'Expired',
|
||||||
daysRemaining: '{{count}}d',
|
daysRemaining: '{{count}}d',
|
||||||
|
hoursMinutesRemaining: '{{hours}}h {{minutes}}m',
|
||||||
|
minutesRemaining: '{{count}}m',
|
||||||
newRequest: 'Set up payment',
|
newRequest: 'Set up payment',
|
||||||
period: 'Period',
|
period: 'Period',
|
||||||
periods: {
|
periods: {
|
||||||
|
|||||||
Reference in New Issue
Block a user