removed tribute

This commit is contained in:
machka pasla
2025-12-11 09:49:31 +03:00
parent 8eba23574b
commit 061fdeb72b
16 changed files with 24 additions and 606 deletions
+3 -6
View File
@@ -11,7 +11,7 @@
- **Пробная подписка:** Система пробных подписок для новых пользователей (активируется вручную по кнопке).
- **Промокоды:** Возможность применять промокоды для получения скидок или бонусных дней.
- **Реферальная программа:** Пользователи могут приглашать друзей и получать за это бонусные дни подписки.
- **Оплата:** Поддержка оплаты через YooKassa, FreeKassa (REST API), Platega, SeverPay, CryptoPay, Telegram Stars и Tribute.
- **Оплата:** Поддержка оплаты через YooKassa, FreeKassa (REST API), Platega, SeverPay, CryptoPay и Telegram Stars.
### Для администраторов:
- **Защищенная админ-панель:** Доступ только для администраторов, указанных в `ADMIN_IDS`.
@@ -80,7 +80,7 @@
| `WEBHOOK_BASE_URL`| **Обязательно.** Базовый URL для вебхуков, например `https://your.domain.com`. |
| `WEB_SERVER_HOST` | Хост для веб-сервера. | `0.0.0.0` |
| `WEB_SERVER_PORT` | Порт для веб-сервера. | `8080` |
| `PAYMENT_METHODS_ORDER` | (Опционально) Порядок отображения кнопок оплаты через запятую. Поддерживаемые ключи: `severpay`, `freekassa`, `platega`, `yookassa`, `tribute`, `stars`, `cryptopay`. Первый будет сверху. |
| `PAYMENT_METHODS_ORDER` | (Опционально) Порядок отображения кнопок оплаты через запятую. Поддерживаемые ключи: `severpay`, `freekassa`, `platega`, `yookassa`, `stars`, `cryptopay`. Первый будет сверху. |
| `YOOKASSA_ENABLED` | Включить/выключить YooKassa (`true`/`false`). |
| `YOOKASSA_SHOP_ID` | ID вашего магазина в YooKassa. |
| `YOOKASSA_SECRET_KEY`| Секретный ключ магазина YooKassa. |
@@ -96,7 +96,6 @@
| `FREEKASSA_PAYMENT_IP` | Внешний IP вашего сервера, который будет передаваться в запрос оплаты. |
| `FREEKASSA_PAYMENT_METHOD_ID` | ID метода оплаты через магазин FreeKassa. По умолчанию `44`. |
| `STARS_ENABLED` | Включить/выключить Telegram Stars (`true`/`false`). |
| `TRIBUTE_ENABLED`| Включить/выключить Tribute (`true`/`false`). |
| `PLATEGA_ENABLED`| Включить/выключить Platega (`true`/`false`). |
| `PLATEGA_MERCHANT_ID`| MerchantId из личного кабинета Platega. |
| `PLATEGA_SECRET`| API секрет для запросов Platega. |
@@ -118,7 +117,6 @@
- `1_MONTH_ENABLED`: `true` или `false`
- `RUB_PRICE_1_MONTH`: Цена в рублях
- `STARS_PRICE_1_MONTH`: Цена в Telegram Stars
- `TRIBUTE_LINK_1_MONTH`: Ссылка для оплаты через Tribute
Аналогичные переменные есть для `3_MONTHS`, `6_MONTHS`, `12_MONTHS`.
</details>
@@ -155,7 +153,7 @@
Эта команда скачает образ и запустит сервис в фоновом режиме.
4. **Настройка вебхуков (Обязательно):**
Вебхуки являются **обязательным** компонентом для работы бота, так как они используются для получения уведомлений от платежных систем (YooKassa, FreeKassa, CryptoPay, Tribute) и панели Remnawave.
Вебхуки являются **обязательным** компонентом для работы бота, так как они используются для получения уведомлений от платежных систем (YooKassa, FreeKassa, CryptoPay, Platega, SeverPay) и панели Remnawave.
Вам понадобится обратный прокси (например, Nginx) для обработки HTTPS-трафика и перенаправления запросов на контейнер с ботом.
@@ -165,7 +163,6 @@
- `https://<ваш_домен>/webhook/platega` → `http://remnawave-tg-shop:<WEB_SERVER_PORT>/webhook/platega`
- `https://<ваш_домен>/webhook/severpay` → `http://remnawave-tg-shop:<WEB_SERVER_PORT>/webhook/severpay`
- `https://<ваш_домен>/webhook/cryptopay` → `http://remnawave-tg-shop:<WEB_SERVER_PORT>/webhook/cryptopay`
- `https://<ваш_домен>/webhook/tribute` → `http://remnawave-tg-shop:<WEB_SERVER_PORT>/webhook/tribute`
- `https://<ваш_домен>/webhook/panel` → `http://remnawave-tg-shop:<WEB_SERVER_PORT>/webhook/panel`
- **Для Telegram:** Бот автоматически установит вебхук, если в `.env` указан `WEBHOOK_BASE_URL`. Путь будет `https://<ваш_домен>/<BOT_TOKEN>`.
-11
View File
@@ -9,7 +9,6 @@ from bot.services.subscription_service import SubscriptionService
from bot.services.referral_service import ReferralService
from bot.services.promo_code_service import PromoCodeService
from bot.services.stars_service import StarsService
from bot.services.tribute_service import TributeService
from bot.services.crypto_pay_service import CryptoPayService
from bot.services.panel_webhook_service import PanelWebhookService
from bot.services.freekassa_service import FreeKassaService
@@ -47,15 +46,6 @@ def build_core_services(
subscription_service=subscription_service,
referral_service=referral_service,
)
tribute_service = TributeService(
bot,
settings,
i18n,
async_session_factory,
panel_service,
subscription_service,
referral_service,
)
platega_service = PlategaService(
bot=bot,
settings=settings,
@@ -100,7 +90,6 @@ def build_core_services(
"stars_service": stars_service,
"cryptopay_service": cryptopay_service,
"freekassa_service": freekassa_service,
"tribute_service": tribute_service,
"panel_webhook_service": panel_webhook_service,
"yookassa_service": yookassa_service,
"platega_service": platega_service,
-7
View File
@@ -29,7 +29,6 @@ async def build_and_start_web_app(
"stars_service",
"freekassa_service",
"cryptopay_service",
"tribute_service",
"panel_webhook_service",
"platega_service",
"severpay_service",
@@ -50,18 +49,12 @@ async def build_and_start_web_app(
)
from bot.handlers.user.payment import yookassa_webhook_route
from bot.services.tribute_service import tribute_webhook_route
from bot.services.crypto_pay_service import cryptopay_webhook_route
from bot.services.panel_webhook_service import panel_webhook_route
from bot.services.freekassa_service import freekassa_webhook_route
from bot.services.platega_service import platega_webhook_route
from bot.services.severpay_service import severpay_webhook_route
tribute_path = settings.tribute_webhook_path
if tribute_path.startswith("/"):
app.router.add_post(tribute_path, tribute_webhook_route)
logging.info(f"Tribute webhook route configured at: [POST] {tribute_path}")
cp_path = settings.cryptopay_webhook_path
if cp_path.startswith("/"):
app.router.add_post(cp_path, cryptopay_webhook_route)
-1
View File
@@ -60,7 +60,6 @@ def format_payment_text(payment: Payment, i18n: JsonI18n, lang: str) -> str:
provider_text = {
'yookassa': 'YooKassa',
'tribute': 'Tribute',
'telegram_stars': 'Telegram Stars',
'cryptopay': 'CryptoPay',
'freekassa': 'FreeKassa',
+12 -23
View File
@@ -125,17 +125,6 @@ async def my_subscription_command_handler(
end_date = active.get("end_date")
days_left = (end_date.date() - datetime.now().date()).days if end_date else 0
tribute_hint = ""
if active.get("status_from_panel", "").lower() == "active":
local_sub = await subscription_dal.get_active_subscription_by_user_id(session, event.from_user.id)
if local_sub:
if local_sub.provider == "tribute":
link = None
link = settings.tribute_payment_links.get(local_sub.duration_months or 1) if hasattr(settings, "tribute_payment_links") else None
tribute_hint = "\n\n" + (
get_text("subscription_tribute_notice_with_link", link=link) if link else get_text("subscription_tribute_notice")
)
text = get_text(
"my_subscription_details",
end_date=end_date.strftime("%Y-%m-%d") if end_date else "N/A",
@@ -224,8 +213,8 @@ async def my_subscription_command_handler(
)
])
# 2) Auto-renew toggle (if supported and not tribute)
if local_sub and local_sub.provider != "tribute" and settings.yookassa_autopayments_active:
# 2) Auto-renew toggle (YooKassa only)
if local_sub and local_sub.provider == "yookassa" and settings.yookassa_autopayments_active:
toggle_text = (
get_text("autorenew_disable_button") if local_sub.auto_renew_enabled else get_text("autorenew_enable_button")
)
@@ -254,17 +243,17 @@ async def my_subscription_command_handler(
except Exception:
pass
try:
await event.message.edit_text(text + tribute_hint, reply_markup=markup, parse_mode="HTML", disable_web_page_preview=True)
await event.message.edit_text(text, reply_markup=markup, parse_mode="HTML", disable_web_page_preview=True)
except Exception:
await bot.send_message(
chat_id=target.chat.id,
text=text + tribute_hint,
text=text,
reply_markup=markup,
parse_mode="HTML",
disable_web_page_preview=True,
)
else:
await target.answer(text + tribute_hint, reply_markup=markup, parse_mode="HTML", disable_web_page_preview=True)
await target.answer(text, reply_markup=markup, parse_mode="HTML", disable_web_page_preview=True)
@router.callback_query(F.data == "main_action:my_devices")
@@ -452,8 +441,8 @@ async def toggle_autorenew_handler(
if not sub or sub.user_id != callback.from_user.id:
await callback.answer(get_text("error_try_again"), show_alert=True)
return
if sub.provider == "tribute":
await callback.answer(get_text("subscription_autorenew_not_supported_for_tribute"), show_alert=True)
if sub.provider != "yookassa":
await callback.answer(get_text("error_try_again"), show_alert=True)
return
if enable:
has_saved_card = await user_billing_dal.user_has_saved_payment_method(session, callback.from_user.id)
@@ -510,8 +499,8 @@ async def confirm_autorenew_handler(
if not sub or sub.user_id != callback.from_user.id:
await callback.answer(get_text("error_try_again"), show_alert=True)
return
if sub.provider == "tribute":
await callback.answer(get_text("subscription_autorenew_not_supported_for_tribute"), show_alert=True)
if sub.provider != "yookassa":
await callback.answer(get_text("error_try_again"), show_alert=True)
return
if enable:
has_saved_card = await user_billing_dal.user_has_saved_payment_method(session, callback.from_user.id)
@@ -549,7 +538,7 @@ async def autorenew_cancel_from_webhook_button(
i18n: Optional[JsonI18n] = i18n_data.get("i18n_instance")
get_text = lambda key, **kwargs: i18n.gettext(current_lang, key, **kwargs) if i18n else key
# Disable auto-renew on the active subscription (non-tribute)
# Disable auto-renew on the active subscription
from db.dal import subscription_dal
sub = await subscription_dal.get_active_subscription_by_user_id(session, callback.from_user.id)
if not sub:
@@ -558,9 +547,9 @@ async def autorenew_cancel_from_webhook_button(
except Exception:
pass
return
if sub.provider == "tribute":
if sub.provider != "yookassa":
try:
await callback.answer(get_text("subscription_autorenew_not_supported_for_tribute"), show_alert=True)
await callback.answer(get_text("error_try_again"), show_alert=True)
except Exception:
pass
return
@@ -52,12 +52,10 @@ async def select_subscription_period_callback_handler(
currency_symbol_val = settings.DEFAULT_CURRENCY_SYMBOL
text_content = get_text("choose_payment_method")
tribute_url = settings.tribute_payment_links.get(months)
stars_price = settings.stars_subscription_options.get(months)
reply_markup = get_payment_method_keyboard(
months,
price_rub,
tribute_url,
stars_price,
currency_symbol_val,
current_lang,
-3
View File
@@ -112,7 +112,6 @@ def get_subscription_options_keyboard(subscription_options: Dict[
def get_payment_method_keyboard(months: int, price: float,
tribute_url: Optional[str],
stars_price: Optional[int],
currency_symbol_val: str, lang: str,
i18n_instance, settings: Settings) -> InlineKeyboardMarkup:
@@ -139,8 +138,6 @@ def get_payment_method_keyboard(months: int, price: float,
text=_("pay_with_yookassa_button"),
callback_data=f"pay_yk:{months}:{price}",
)
elif method == "tribute" and settings.TRIBUTE_ENABLED and tribute_url:
builder.button(text=_("pay_with_tribute_button"), url=tribute_url)
elif method == "stars" and settings.STARS_ENABLED and stars_price is not None:
builder.button(
text=_("pay_with_stars_button"),
-2
View File
@@ -33,7 +33,6 @@ from bot.services.subscription_service import SubscriptionService
from bot.services.referral_service import ReferralService
from bot.services.promo_code_service import PromoCodeService
from bot.services.stars_service import StarsService
from bot.services.tribute_service import TributeService, tribute_webhook_route
from bot.services.crypto_pay_service import CryptoPayService, cryptopay_webhook_route
from bot.handlers.user import payment as user_payment_webhook_module
@@ -200,7 +199,6 @@ async def on_shutdown_configured(dispatcher: Dispatcher):
"panel_service",
"cryptopay_service",
"freekassa_service",
"tribute_service",
"panel_webhook_service",
"yookassa_service",
"promo_code_service",
-1
View File
@@ -240,7 +240,6 @@ class NotificationService:
"freekassa": "💳",
"cryptopay": "",
"stars": "",
"tribute": "💎",
"platega": "💳",
"severpay": "💳",
}.get(payment_provider.lower(), "💰")
+4 -131
View File
@@ -12,7 +12,6 @@ from .panel_api_service import PanelApiService
from bot.middlewares.i18n import JsonI18n
from bot.keyboards.inline.user_keyboards import get_subscribe_only_markup, get_autorenew_cancel_keyboard
from db.dal import user_dal
from bot.utils.date_utils import add_months
EVENT_MAP = {
"user.expires_in_72_hours": (3, "subscription_72h_notification"),
@@ -44,128 +43,6 @@ class PanelWebhookService:
except Exception as e:
logging.error(f"Failed to send notification to {user_id}: {e}")
async def _handle_expired_subscription(self, session, user_id: int, user_payload: dict,
lang: str, markup, first_name: str) -> bool:
"""Handle expired subscription - auto-renew tribute users if no cancellation was received.
Returns True if an auto-renewal was performed (and renewal message sent), False otherwise.
"""
from db.dal import subscription_dal, payment_dal
from datetime import datetime, timezone
try:
auto_renewed = False
# Check if user has tribute subscriptions that weren't cancelled
user_subs = await subscription_dal.get_active_subscriptions_for_user(session, user_id)
for sub in user_subs:
# Check if this subscription was marked as cancelled (from tribute cancellation webhook)
if sub.status_from_panel == 'CANCELLED':
logging.info(f"Subscription {sub.subscription_id} for user {user_id} was cancelled, skipping auto-renewal")
continue
# Check if this user has tribute payments
last_tribute_duration = await payment_dal.get_last_tribute_payment_duration(session, user_id)
if last_tribute_duration is not None:
# This user has tribute payments, auto-renew for the same duration
logging.info(f"Auto-renewing tribute subscription for user {user_id} for {last_tribute_duration} months")
# Extend subscription by the last payment duration (calendar months)
new_end_date = add_months(datetime.now(timezone.utc), last_tribute_duration)
# Update local DB subscription
await subscription_dal.update_subscription(
session,
sub.subscription_id,
{
'end_date': new_end_date,
'status_from_panel': 'ACTIVE',
'is_active': True
}
)
# Update panel expiry to ensure actual service access is extended
try:
panel_payload = {
"uuid": sub.panel_user_uuid,
"expireAt": new_end_date.isoformat(timespec='milliseconds').replace('+00:00', 'Z'),
"status": "ACTIVE",
}
panel_update_resp = await self.panel_service.update_user_details_on_panel(
sub.panel_user_uuid,
panel_payload,
log_response=True,
)
if panel_update_resp:
logging.info(
f"Panel expiry updated for user {user_id} (panel_uuid {sub.panel_user_uuid}) to {new_end_date}"
)
except Exception as e_panel:
logging.error(
f"Failed to update panel expiry for user {user_id} (panel_uuid {sub.panel_user_uuid}): {e_panel}")
# Create a succeeded payment record in DB with the same amount/currency as last tribute payment
try:
last_payment = await payment_dal.get_last_tribute_payment(session, user_id)
if last_payment and last_payment.amount and last_payment.currency:
provider_payment_id = (
f"tribute_auto_{user_id}_{sub.subscription_id}_"
f"{new_end_date.strftime('%Y%m%d')}"
)
created_payment = await payment_dal.ensure_payment_with_provider_id(
session,
user_id=user_id,
amount=float(last_payment.amount),
currency=last_payment.currency,
months=last_tribute_duration,
description="Auto-renewal (panel webhook)",
provider="tribute",
provider_payment_id=provider_payment_id,
)
if created_payment:
logging.info(
f"Auto-renew payment recorded (id={created_payment.payment_id}) for user {user_id} amount={created_payment.amount} {created_payment.currency} months={last_tribute_duration}"
)
else:
logging.warning(
f"Could not create auto-renew payment for user {user_id}: previous tribute payment not found or missing amount/currency")
except Exception as e_pay:
logging.error(
f"Failed to create auto-renew payment record for user {user_id}: {e_pay}",
exc_info=True,
)
# Send auto-renewal notification
_ = lambda k, **kw: self.i18n.gettext(lang, k, **kw) if self.i18n else k
auto_renewal_msg = _(
"tribute_auto_renewal",
default="🔄 <b>Подписка автоматически продлена</b>\n\n"
"Ваша подписка Tribute была автоматически продлена на {months} мес.\n"
"Новая дата окончания: {end_date}",
user_name=first_name,
months=last_tribute_duration,
end_date=new_end_date.strftime('%Y-%m-%d')
)
try:
await self.bot.send_message(
user_id,
auto_renewal_msg,
reply_markup=markup,
parse_mode="HTML"
)
auto_renewed = True
except Exception as e:
logging.error(f"Failed to send auto-renewal notification to user {user_id}: {e}")
await session.commit()
return auto_renewed
except Exception as e:
logging.error(f"Error handling expired subscription for user {user_id}: {e}")
await session.rollback()
return False
async def handle_event(self, event_name: str, user_payload: dict):
telegram_id = user_payload.get("telegramId")
if not telegram_id:
@@ -193,7 +70,7 @@ class PanelWebhookService:
async with self.async_session_factory() as session:
from db.dal import subscription_dal
sub = await subscription_dal.get_active_subscription_by_user_id(session, user_id)
if sub and sub.auto_renew_enabled and sub.provider != 'tribute':
if sub and sub.auto_renew_enabled and sub.provider == 'yookassa':
try:
ok = await subscription_service.charge_subscription_renewal(session, sub)
# If initiation succeeded, suppress the 24h reminder by returning early
@@ -208,7 +85,7 @@ class PanelWebhookService:
except Exception:
logging.exception("Auto-renew trigger (24h) failed pre-check")
if days_left <= self.settings.SUBSCRIPTION_NOTIFY_DAYS_BEFORE:
# For 48h event, if auto-renew is enabled and not tribute, show special notice with cancel button
# For 48h event, if auto-renew is enabled, show special notice with cancel button
if days_left == 2:
async with self.async_session_factory() as session:
from db.dal import subscription_dal
@@ -220,7 +97,7 @@ class PanelWebhookService:
getattr(sub, 'auto_renew_enabled', None) if sub else None,
getattr(sub, 'provider', None) if sub else None,
)
if sub and sub.auto_renew_enabled and sub.provider != 'tribute':
if sub and sub.auto_renew_enabled and sub.provider == 'yookassa':
cancel_kb = get_autorenew_cancel_keyboard(lang, self.i18n)
await self._send_message(
user_id,
@@ -239,11 +116,7 @@ class PanelWebhookService:
end_date=user_payload.get("expireAt", "")[:10],
)
elif event_name == "user.expired":
# Check if this is a tribute user that should be auto-renewed (regardless of notification settings)
auto_renewed = await self._handle_expired_subscription(session, user_id, user_payload, lang, markup, first_name)
# If auto-renewed via Tribute, suppress expiration notification. Otherwise, send it if enabled.
if not auto_renewed and self.settings.SUBSCRIPTION_NOTIFY_ON_EXPIRE:
if self.settings.SUBSCRIPTION_NOTIFY_ON_EXPIRE:
await self._send_message(
user_id,
lang,
+3 -3
View File
@@ -517,7 +517,7 @@ class SubscriptionService:
"status_from_panel": "ACTIVE",
"traffic_limit_bytes": self.settings.user_traffic_limit_bytes,
"provider": provider,
"skip_notifications": provider == "tribute" and self.settings.TRIBUTE_SKIP_NOTIFICATIONS,
"skip_notifications": False,
"auto_renew_enabled": auto_renew_should_enable,
}
try:
@@ -828,8 +828,8 @@ class SubscriptionService:
# If autopayments are disabled globally, skip charging attempts
if not self.settings.yookassa_autopayments_active:
return True
if sub.provider == "tribute":
# Tribute is paid externally; we do not auto-charge here
if sub.provider != "yookassa":
logging.info("Auto-renew skipped: provider %s does not support auto-renew", sub.provider)
return True
from db.dal.user_billing_dal import get_user_default_payment_method
-335
View File
@@ -1,335 +0,0 @@
import logging
import hmac
import hashlib
import json
from typing import Optional
from aiohttp import web
from aiogram import Bot
from sqlalchemy.orm import sessionmaker
from config.settings import Settings
from bot.middlewares.i18n import JsonI18n
from bot.services.subscription_service import SubscriptionService
from bot.services.panel_api_service import PanelApiService
from bot.services.referral_service import ReferralService
from .notification_service import NotificationService
from bot.keyboards.inline.user_keyboards import get_connect_and_main_keyboard
from db.dal import payment_dal, user_dal, subscription_dal
from bot.utils.text_sanitizer import sanitize_display_name, username_for_display
def convert_period_to_months(period: Optional[str]) -> int:
"""Map Tribute subscription period strings to months."""
if not period:
return 1
mapping = {
"monthly": 1,
"quarterly": 3,
"3-month": 3,
"3months": 3,
"3-months": 3,
"q": 3,
"halfyearly": 6,
"yearly": 12,
"annual": 12,
"y": 12,
}
return mapping.get(period.lower(), 1)
class TributeService:
def __init__(
self,
bot: Bot,
settings: Settings,
i18n: JsonI18n,
async_session_factory: sessionmaker,
panel_service: PanelApiService,
subscription_service: SubscriptionService,
referral_service: ReferralService,
):
self.bot = bot
self.settings = settings
self.i18n = i18n
self.async_session_factory = async_session_factory
self.panel_service = panel_service
self.subscription_service = subscription_service
self.referral_service = referral_service
async def handle_webhook(self, raw_body: bytes, signature_header: Optional[str]) -> web.Response:
settings = self.settings
bot = self.bot
i18n = self.i18n
async_session_factory = self.async_session_factory
subscription_service = self.subscription_service
referral_service = self.referral_service
def ok(data: Optional[dict] = None) -> web.Response:
payload = {"status": "ok"}
if data:
payload.update(data)
return web.json_response(payload, status=200)
def ignored(reason: str) -> web.Response:
return web.json_response({"status": "ignored", "reason": reason}, status=200)
def bad_request(reason: str) -> web.Response:
return web.json_response({"status": "error", "reason": reason}, status=400)
if settings.TRIBUTE_API_KEY:
if not signature_header:
return web.json_response({"status": "error", "reason": "no_signature"}, status=403)
expected_sig = hmac.new(settings.TRIBUTE_API_KEY.encode(), raw_body,
hashlib.sha256).hexdigest()
if not hmac.compare_digest(expected_sig, signature_header):
return web.json_response({"status": "error", "reason": "invalid_signature"}, status=403)
try:
payload = json.loads(raw_body.decode())
except Exception:
return bad_request("invalid_json")
logging.info(
"Tribute webhook data: %s",
json.dumps(payload, ensure_ascii=False),
)
# Tribute webhook spec: only two events are sent
# name: new_subscription | cancelled_subscription
event_name = payload.get("name")
data = payload.get("payload", {})
# Mandatory routing fields
user_id = data.get("telegram_user_id")
if not user_id:
# Permanent format issue — acknowledge to avoid retries
return ignored("missing_telegram_user_id")
period_val = data.get("period")
months = convert_period_to_months(period_val)
# Tribute sends amount in minor units (kopecks/cents). Convert to major units before persisting.
amount_value = data.get("amount") or data.get("price")
currency = (data.get("currency") or settings.DEFAULT_CURRENCY_SYMBOL or "RUB").upper()
if amount_value is not None:
try:
amount_minor_units = float(amount_value)
except (TypeError, ValueError):
amount_minor_units = 0.0
amount_float = round(amount_minor_units / 100.0, 2)
else:
amount_float = 0.0
async with async_session_factory() as session:
if event_name == "new_subscription":
# Use a unique, idempotent provider payment id per webhook event
# Prefer explicit event/payment identifiers if present; otherwise fall back to payload hash suffix
candidate_event_id = (
str(data.get("event_id") or data.get("payment_id") or data.get("purchase_id") or data.get("invoice_id") or "")
)
if candidate_event_id:
provider_payment_id = candidate_event_id
else:
# Combine subscription_id (if any) with a stable hash of the raw payload to ensure uniqueness per event
sub_id_part = str(data.get("subscription_id") or "sub")
payload_hash = hashlib.sha256(raw_body).hexdigest()[:16]
provider_payment_id = f"{sub_id_part}:{payload_hash}"
# Idempotent ensure payment
payment_record = await payment_dal.ensure_payment_with_provider_id(
session,
user_id=int(user_id),
amount=amount_float,
currency=currency,
months=months,
description="Tribute subscription",
provider="tribute",
provider_payment_id=provider_payment_id,
)
activation_details = await subscription_service.activate_subscription(
session,
int(user_id),
months,
float(amount_float),
payment_record.payment_id,
provider="tribute",
)
referral_bonus = await referral_service.apply_referral_bonuses_for_payment(
session,
int(user_id),
months,
current_payment_db_id=payment_record.payment_id,
skip_if_active_before_payment=False,
)
await session.commit()
db_user = await user_dal.get_user_by_id(session, int(user_id))
lang = db_user.language_code if db_user and db_user.language_code else settings.DEFAULT_LANGUAGE
_ = lambda k, **kw: i18n.gettext(lang, k, **kw)
applied_ref_days = referral_bonus.get('referee_bonus_applied_days') if referral_bonus else None
final_end = (referral_bonus.get('referee_new_end_date')
if referral_bonus else None)
if not final_end:
final_end = activation_details.get('end_date')
if final_end:
config_link = activation_details.get("subscription_url") or _(
"config_link_not_available"
)
if applied_ref_days:
inviter_name_display = _('friend_placeholder')
if db_user and db_user.referred_by_id:
inviter = await user_dal.get_user_by_id(session, db_user.referred_by_id)
if inviter:
safe_name = sanitize_display_name(inviter.first_name) if inviter.first_name else None
if safe_name:
inviter_name_display = safe_name
elif inviter.username:
inviter_name_display = username_for_display(inviter.username, with_at=False)
success_msg = _(
"payment_successful_with_referral_bonus_full",
months=months,
base_end_date=activation_details["end_date"].strftime('%Y-%m-%d'),
bonus_days=applied_ref_days,
final_end_date=final_end.strftime('%Y-%m-%d'),
inviter_name=inviter_name_display,
config_link=config_link,
)
else:
success_msg = _(
"payment_successful_full",
months=months,
end_date=final_end.strftime('%Y-%m-%d'),
config_link=config_link,
)
markup = get_connect_and_main_keyboard(
lang,
i18n,
settings,
config_link,
preserve_message=True,
)
try:
# Use user's DB language in success messages prepared above
await bot.send_message(
int(user_id),
success_msg,
reply_markup=markup,
parse_mode="HTML",
disable_web_page_preview=True,
)
except Exception as e:
logging.error(
f"Failed to send Tribute payment success message to user {user_id}: {e}")
# Send notification about payment
try:
notification_service = NotificationService(bot, settings, i18n)
user = await user_dal.get_user_by_id(session, int(user_id))
await notification_service.notify_payment_received(
user_id=int(user_id),
amount=float(amount_float),
currency=currency,
months=months,
payment_provider="tribute",
username=user.username if user else None
)
except Exception as e:
logging.error(f"Failed to send tribute payment notification: {e}")
elif event_name == "cancelled_subscription":
await self._handle_tribute_cancellation(session, int(user_id), bot, i18n)
else:
await session.commit()
# Acknowledge to Tribute that webhook was received and processed/accepted
return ok({"event": event_name or "unknown"})
async def _handle_tribute_cancellation(self, session, user_id: int, bot: Bot, i18n: JsonI18n):
"""Handle tribute subscription cancellation - set subscription to 1 day grace period"""
from datetime import datetime, timezone, timedelta
from db.dal import subscription_dal, user_dal
from bot.keyboards.inline.user_keyboards import get_subscribe_only_markup
try:
grace_days = 1
grace_end = datetime.now(timezone.utc) + timedelta(days=grace_days)
active_subscriptions = await subscription_dal.get_active_subscriptions_for_user(session, user_id)
panel_users_updated: set[str] = set()
for sub in active_subscriptions:
updated_sub = await subscription_dal.update_subscription(
session,
sub.subscription_id,
{
"end_date": grace_end,
"status_from_panel": "CANCELLED",
"skip_notifications": True,
},
)
panel_uuid = updated_sub.panel_user_uuid if updated_sub else None
if panel_uuid and panel_uuid not in panel_users_updated:
panel_users_updated.add(panel_uuid)
panel_payload = {
"expireAt": grace_end.isoformat(timespec="milliseconds").replace("+00:00", "Z"),
}
try:
await self.panel_service.update_user_details_on_panel(
panel_uuid,
panel_payload,
log_response=False,
)
except Exception as panel_err:
logging.error(
f"Failed to update panel expiry for user {user_id} (panel_uuid {panel_uuid}) during Tribute cancellation: {panel_err}")
await session.commit()
# Send notification about cancellation if enabled
if not self.settings.TRIBUTE_SKIP_CANCELLATION_NOTIFICATIONS:
db_user = await user_dal.get_user_by_id(session, user_id)
lang = db_user.language_code if db_user and db_user.language_code else self.settings.DEFAULT_LANGUAGE
first_name = db_user.first_name or f"User {user_id}" if db_user else f"User {user_id}"
_ = lambda k, **kw: i18n.gettext(lang, k, **kw) if i18n else k
markup = get_subscribe_only_markup(lang, i18n)
cancellation_msg = _(
"tribute_subscription_cancelled",
default="🚨 <b>Подписка отменена</b>\n\n"
"Ваша подписка Tribute была отменена. У вас есть 24 часа для восстановления доступа, "
"после чего подписка будет заблокирована.\n\n"
"Для продления подписки нажмите кнопку ниже.",
user_name=first_name
)
try:
await bot.send_message(
int(user_id),
cancellation_msg,
reply_markup=markup,
parse_mode="HTML"
)
except Exception as e:
logging.error(f"Failed to send tribute cancellation notification to user {user_id}: {e}")
logging.info(f"Tribute subscription cancelled for user {user_id}, grace period set to 1 day")
except Exception as e:
logging.error(f"Error handling tribute cancellation for user {user_id}: {e}")
await session.rollback()
async def tribute_webhook_route(request: web.Request):
"""AIOHTTP route handler for Tribute webhook calls."""
tribute_service: TributeService = request.app['tribute_service']
raw_body = await request.read()
signature_header = request.headers.get('trbt-signature')
return await tribute_service.handle_webhook(raw_body, signature_header)
+1 -43
View File
@@ -84,10 +84,9 @@ class Settings(BaseSettings):
YOOKASSA_ENABLED: bool = Field(default=True)
STARS_ENABLED: bool = Field(default=True)
TRIBUTE_ENABLED: bool = Field(default=True)
PAYMENT_METHODS_ORDER: Optional[str] = Field(
default=None,
description="Comma-separated list of payment methods to show (e.g., severpay,freekassa,yookassa,platega,stars,cryptopay,tribute)",
description="Comma-separated list of payment methods to show (e.g., severpay,freekassa,yookassa,platega,stars,cryptopay)",
)
MONTH_1_ENABLED: bool = Field(default=True, alias="1_MONTH_ENABLED")
@@ -104,15 +103,6 @@ class Settings(BaseSettings):
STARS_PRICE_3_MONTHS: Optional[int] = Field(default=None)
STARS_PRICE_6_MONTHS: Optional[int] = Field(default=None)
STARS_PRICE_12_MONTHS: Optional[int] = Field(default=None)
TRIBUTE_LINK_1_MONTH: Optional[str] = Field(default=None)
TRIBUTE_LINK_3_MONTHS: Optional[str] = Field(default=None)
TRIBUTE_LINK_6_MONTHS: Optional[str] = Field(default=None)
TRIBUTE_LINK_12_MONTHS: Optional[str] = Field(default=None)
TRIBUTE_API_KEY: Optional[str] = Field(default=None)
TRIBUTE_SKIP_NOTIFICATIONS: bool = Field(default=True, description="Skip renewal notifications for Tribute payments")
TRIBUTE_SKIP_CANCELLATION_NOTIFICATIONS: bool = Field(default=False, description="Skip cancellation notifications for Tribute payments")
PANEL_WEBHOOK_SECRET: Optional[str] = Field(default=None)
SUBSCRIPTION_NOTIFICATIONS_ENABLED: bool = Field(default=True)
@@ -265,21 +255,6 @@ class Settings(BaseSettings):
return f"{base.rstrip('/')}{self.yookassa_webhook_path}"
return None
@computed_field
@property
def tribute_webhook_path(self) -> str:
return "/webhook/tribute"
@computed_field
@property
def tribute_full_webhook_url(self) -> Optional[str]:
base = self.WEBHOOK_BASE_URL
if base:
return f"{base.rstrip('/')}{self.tribute_webhook_path}"
return None
@computed_field
@property
def panel_webhook_path(self) -> str:
return "/webhook/panel"
@@ -385,22 +360,6 @@ class Settings(BaseSettings):
options[12] = self.STARS_PRICE_12_MONTHS
return options
@computed_field
@property
def tribute_payment_links(self) -> Dict[int, str]:
links: Dict[int, str] = {}
if self.TRIBUTE_ENABLED and self.MONTH_1_ENABLED and self.TRIBUTE_LINK_1_MONTH:
links[1] = self.TRIBUTE_LINK_1_MONTH
if self.TRIBUTE_ENABLED and self.MONTH_3_ENABLED and self.TRIBUTE_LINK_3_MONTHS:
links[3] = self.TRIBUTE_LINK_3_MONTHS
if self.TRIBUTE_ENABLED and self.MONTH_6_ENABLED and self.TRIBUTE_LINK_6_MONTHS:
links[6] = self.TRIBUTE_LINK_6_MONTHS
if self.TRIBUTE_ENABLED and self.MONTH_12_ENABLED and self.TRIBUTE_LINK_12_MONTHS:
links[12] = self.TRIBUTE_LINK_12_MONTHS
return links
@computed_field
@property
def referral_bonus_inviter(self) -> Dict[int, int]:
bonuses: Dict[int, int] = {}
if self.REFERRAL_BONUS_DAYS_INVITER_1_MONTH is not None:
@@ -444,7 +403,6 @@ class Settings(BaseSettings):
"platega",
"severpay",
"yookassa",
"tribute",
"stars",
"cryptopay",
]
+1 -26
View File
@@ -239,31 +239,6 @@ async def get_financial_statistics(session: AsyncSession) -> Dict[str, Any]:
}
async def get_last_tribute_payment_duration(session: AsyncSession, user_id: int) -> Optional[int]:
"""Get duration in months from the last successful tribute payment for a user."""
stmt = select(Payment.subscription_duration_months).where(
and_(
Payment.user_id == user_id,
Payment.provider == 'tribute',
Payment.status == 'succeeded'
)
).order_by(Payment.created_at.desc()).limit(1)
result = await session.execute(stmt)
return result.scalar_one_or_none()
async def get_last_tribute_payment(
session: AsyncSession, user_id: int) -> Optional[Payment]:
"""Return the most recent succeeded Tribute payment for the user."""
stmt = (select(Payment).where(
and_(Payment.user_id == user_id, Payment.provider == 'tribute',
Payment.status == 'succeeded')).order_by(
Payment.created_at.desc()).limit(1))
result = await session.execute(stmt)
return result.scalar_one_or_none()
async def get_user_total_paid(session: AsyncSession, user_id: int) -> float:
"""Get total amount paid by a specific user (sum of all succeeded payments)."""
stmt = select(func.sum(Payment.amount)).where(
@@ -295,4 +270,4 @@ async def get_referral_revenue(session: AsyncSession, referrer_id: int) -> float
)
result = await session.execute(stmt)
total = result.scalar()
return float(total or 0)
return float(total or 0)
-6
View File
@@ -41,7 +41,6 @@
"pay_with_severpay_button": "💳 SeverPay",
"back_to_payment_methods_button": "⬅️ Back",
"pay_with_cryptopay_button": "💎 CryptoBot",
"pay_with_tribute_button": "❤️ Tribute",
"pay_with_stars_button": "🌟 Telegram Stars",
"connect_button": "🔗 Connect",
"cancel_button": "❌ Cancel",
@@ -240,8 +239,6 @@
"autorenew_48h_charge_tomorrow_notice": "🔔 Reminder\n\nTomorrow an automatic charge will occur to renew your subscription. If you don't want auto-renew, disable it using the button below.",
"autorenew_confirm_enable": "🔄 Enable auto-renew? An automatic charge will be attempted before your subscription ends.",
"autorenew_confirm_disable": "🛑 Disable auto-renew? No further automatic charges will occur.",
"tribute_subscription_cancelled": "🚨 <b>Subscription Cancelled</b>\n\nYour Tribute subscription has been cancelled. You have 24 hours to restore access, after which the subscription will be blocked.\n\nTo renew your subscription, press the button below.",
"tribute_auto_renewal": "🔄 <b>Subscription Auto-Renewed</b>\n\nYour Tribute subscription has been automatically renewed for {months} months.\nNew expiration date: {end_date}",
"yookassa_auto_renewal": "🔄 <b>Subscription Auto-Renewed</b>\n\nYour subscription was automatically renewed for {months} month(s).\nNew expiration date: {end_date}",
"admin_user_management_prompt": "👤 User Management\n\nEnter user ID or @username to search:",
"admin_user_subscription_info": "Subscription Information:",
@@ -442,9 +439,6 @@
"payment_method_tx_history_title": "📜 Transactions history",
"payment_method_no_history": "No transactions history.",
"subscription_purchase_title": "Subscription purchase for {months} mo.",
"subscription_tribute_notice": "Paid via Tribute. Renew using your Tribute link.",
"subscription_tribute_notice_with_link": "Paid via Tribute. Renew: {link}",
"subscription_autorenew_not_supported_for_tribute": "Auto-renew is handled by Tribute. Manage renewal in the Tribute app/link.",
"autorenew_enable_requires_card": "Link a payment card in Payment Methods before enabling auto-renew.",
"subscription_not_active": "You don't have an active subscription.",
"error_service_unavailable": "Service unavailable. Please try again later.",
-6
View File
@@ -41,7 +41,6 @@
"pay_with_severpay_button": "💳 SeverPay",
"back_to_payment_methods_button": "⬅️ Назад",
"pay_with_cryptopay_button": "💎 CryptoBot",
"pay_with_tribute_button": "❤️ Tribute",
"pay_with_stars_button": "🌟 Звезды Telegram",
"connect_button": "🔗 Подключиться",
@@ -169,7 +168,6 @@
"autorenew_48h_charge_tomorrow_notice": "🔔 Напоминание\n\nЗавтра будет автоматическое списание за продление подписки. Если вы не хотите автопродление — отключите его кнопкой ниже.",
"autorenew_confirm_enable": "🔄 Включить автопродление? Перед окончанием подписки будет выполняться автосписание.",
"autorenew_confirm_disable": "🛑 Отключить автопродление? Автосписаний больше не будет.",
"tribute_subscription_cancelled": "🚨 <b>Подписка отменена</b>\n\nВаша подписка Tribute была отменена. У вас есть 24 часа для восстановления доступа, после чего подписка будет заблокирована.\n\nДля продления подписки нажмите кнопку ниже.",
"yookassa_auto_renewal": "🔄 <b>Подписка автоматически продлена</b>\n\nВаша подписка была автоматически продлена на {months} мес.\nНовая дата окончания: {end_date}",
"admin_promo_set_validity_days": "⏰ Установить срок (дни)",
"admin_back_to_panel": "⬅️ В панель",
@@ -242,7 +240,6 @@
"admin_broadcast_invalid_html": "❌ Некорректный HTML в сообщении. Пожалуйста, отправьте корректный HTML (поддерживаются теги Telegram) или уберите теги.",
"error_displaying_logs_too_long": "Ошибка: логи слишком длинные для отображения одним сообщением. Попробуйте найти логи по конкретному пользователю.",
"error_displaying_statistics": "Ошибка отображения статистики.",
"tribute_auto_renewal": "🔄 <b>Подписка автоматически продлена</b>\n\nВаша подписка Tribute была автоматически продлена на {months} мес.\nНовая дата окончания: {end_date}",
"admin_user_management_prompt": "👤 Управление пользователями\n\nВведите ID пользователя или @username для поиска:",
"admin_user_subscription_info": "Информация о подписке:",
"admin_user_reset_trial_button": "🔄 Сбросить триал",
@@ -442,9 +439,6 @@
"payment_method_tx_history_title": "📜 История операций",
"payment_method_no_history": "История операций отсутствует.",
"subscription_purchase_title": "Покупка подписки на {months} мес.",
"subscription_tribute_notice": "Оплачено через Tribute. Продление делайте по ссылке Tribute.",
"subscription_tribute_notice_with_link": "Оплачено через Tribute. Продлить: {link}",
"subscription_autorenew_not_supported_for_tribute": "Автопродление управляется Tribute. Управляйте продлением в приложении/ссылке Tribute.",
"autorenew_enable_requires_card": "Прежде чем включать автоплатёж, привяжите карту в разделе «Способы оплаты».",
"subscription_not_active": "У вас нет активной подписки.",
"error_service_unavailable": "Сервис недоступен. Попробуйте позже.",