removed tribute
This commit is contained in:
@@ -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,
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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"),
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -240,7 +240,6 @@ class NotificationService:
|
||||
"freekassa": "💳",
|
||||
"cryptopay": "₿",
|
||||
"stars": "⭐",
|
||||
"tribute": "💎",
|
||||
"platega": "💳",
|
||||
"severpay": "💳",
|
||||
}.get(payment_provider.lower(), "💰")
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
Reference in New Issue
Block a user