Notify users after Tribute payment

This commit is contained in:
Machka Pasla
2025-06-23 23:42:24 +03:00
parent 53464ab25c
commit aa20ac9a18
14 changed files with 445 additions and 37 deletions
+2 -1
View File
@@ -105,7 +105,8 @@ async def process_successful_payment(session: AsyncSession, bot: Bot,
subscription_months,
payment_value,
payment_db_id,
promo_code_id_from_payment=promo_code_id)
promo_code_id_from_payment=promo_code_id,
provider="yookassa")
if not activation_details or not activation_details.get('end_date'):
logging.error(
+173 -16
View File
@@ -1,19 +1,20 @@
import logging
from aiogram import Router, F, types, Bot
from aiogram.filters import Command
from aiogram.types import InlineKeyboardButton, InlineKeyboardMarkup
from aiogram.types import InlineKeyboardButton, InlineKeyboardMarkup, LabeledPrice
from typing import Optional, Dict, Any, Union
from datetime import datetime, timezone
from sqlalchemy.ext.asyncio import AsyncSession
from config.settings import Settings
from db.dal import payment_dal
from db.dal import payment_dal, user_dal
from bot.keyboards.inline.user_keyboards import (
get_subscription_options_keyboard, get_confirm_subscription_keyboard,
get_subscription_options_keyboard, get_payment_method_keyboard,
get_payment_url_keyboard, get_back_to_main_menu_markup)
from bot.services.payment_service import YooKassaService
from bot.services.subscription_service import SubscriptionService
from bot.services.panel_api_service import PanelApiService
from bot.services.referral_service import ReferralService
from bot.middlewares.i18n import JsonI18n
router = Router(name="user_subscription_router")
@@ -99,28 +100,101 @@ async def select_subscription_period_callback_handler(
return
currency_symbol_val = settings.DEFAULT_CURRENCY_SYMBOL
confirmation_text_content = get_text("confirm_subscription_prompt",
months=months,
price=f"{price_rub:.2f}",
currency_symbol=currency_symbol_val)
reply_markup = get_confirm_subscription_keyboard(months, price_rub,
currency_symbol_val,
current_lang, i18n)
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,
i18n,
)
try:
await callback.message.edit_text(confirmation_text_content,
await callback.message.edit_text(text_content,
reply_markup=reply_markup)
except Exception as e_edit:
logging.warning(
f"Edit message for subscription confirmation failed: {e_edit}. Sending new one."
f"Edit message for payment method selection failed: {e_edit}. Sending new one."
)
await callback.message.answer(confirmation_text_content,
await callback.message.answer(text_content,
reply_markup=reply_markup)
await callback.answer()
@router.callback_query(F.data.startswith("confirm_sub:"))
async def confirm_subscription_callback_handler(
@router.callback_query(F.data.startswith("pay_stars:"))
async def pay_stars_callback_handler(
callback: types.CallbackQuery, settings: Settings, i18n_data: dict,
session: AsyncSession, bot: Bot):
current_lang = i18n_data.get("current_language", settings.DEFAULT_LANGUAGE)
i18n: Optional[JsonI18n] = i18n_data.get("i18n_instance")
get_text = lambda key, **kwargs: i18n.gettext(current_lang, key, **kwargs) if i18n else key
if not i18n or not callback.message:
await callback.answer(get_text("error_occurred_try_again"), show_alert=True)
return
try:
_, data_payload = callback.data.split(":", 1)
months_str, price_str = data_payload.split(":")
months = int(months_str)
stars_price = int(price_str)
except (ValueError, IndexError):
logging.error(f"Invalid pay_stars data in callback: {callback.data}")
await callback.answer(get_text("error_try_again"), show_alert=True)
return
user_id = callback.from_user.id
payment_description = get_text("payment_description_subscription", months=months)
payment_record_data = {
"user_id": user_id,
"amount": float(stars_price),
"currency": "XTR",
"status": "pending_stars",
"description": payment_description,
"subscription_duration_months": months,
"provider": "telegram_stars",
}
try:
db_payment_record = await payment_dal.create_payment_record(session, payment_record_data)
await session.commit()
except Exception as e_db_payment:
await session.rollback()
logging.error(f"Failed to create stars payment record: {e_db_payment}", exc_info=True)
await callback.message.edit_text(get_text("error_creating_payment_record"))
await callback.answer(get_text("error_try_again"), show_alert=True)
return
payload = f"{db_payment_record.payment_id}:{months}"
prices = [LabeledPrice(label=payment_description, amount=stars_price)]
try:
await bot.send_invoice(
chat_id=user_id,
title=payment_description,
description=payment_description,
payload=payload,
provider_token="",
currency="XTR",
prices=prices,
)
except Exception as e_inv:
logging.error(f"Failed to send Telegram Stars invoice: {e_inv}", exc_info=True)
await callback.message.edit_text(get_text("error_payment_gateway"))
await callback.answer(get_text("error_try_again"), show_alert=True)
return
await callback.answer()
@router.callback_query(F.data.startswith("pay_yk:"))
async def pay_yk_callback_handler(
callback: types.CallbackQuery, settings: Settings, i18n_data: dict,
yookassa_service: YooKassaService, session: AsyncSession):
current_lang = i18n_data.get("current_language", settings.DEFAULT_LANGUAGE)
@@ -151,7 +225,7 @@ async def confirm_subscription_callback_handler(
price_rub = float(price_str)
except (ValueError, IndexError):
logging.error(
f"Invalid confirmation data in callback: {callback.data}")
f"Invalid pay_yk data in callback: {callback.data}")
await callback.answer(get_text("error_try_again"), show_alert=True)
return
@@ -342,6 +416,89 @@ async def my_subscription_command_handler(
await target.answer(text, reply_markup=markup, parse_mode="HTML", disable_web_page_preview=True)
@router.pre_checkout_query()
async def stars_pre_checkout_handler(pre_checkout_query: types.PreCheckoutQuery):
await pre_checkout_query.answer(ok=True)
@router.message(F.successful_payment)
async def stars_successful_payment_handler(
message: types.Message, settings: Settings, i18n_data: dict,
session: AsyncSession, bot: Bot, panel_service: PanelApiService,
subscription_service: SubscriptionService, referral_service: ReferralService):
sp = message.successful_payment
if not sp or sp.currency != "XTR":
return
payload = sp.invoice_payload or ""
try:
payment_id_str, months_str = payload.split(":")
payment_db_id = int(payment_id_str)
months = int(months_str)
except (ValueError, IndexError):
logging.error(f"Invalid invoice payload for stars payment: {payload}")
return
provider_payment_id = sp.provider_payment_charge_id
stars_amount = sp.total_amount
try:
await payment_dal.update_provider_payment_and_status(
session, payment_db_id, provider_payment_id, "succeeded")
await session.commit()
except Exception as e_upd:
await session.rollback()
logging.error(f"Failed to update stars payment record {payment_db_id}: {e_upd}", exc_info=True)
return
activation_details = await subscription_service.activate_subscription(
session,
message.from_user.id,
months,
float(stars_amount),
payment_db_id,
provider="telegram_stars",
)
if not activation_details or not activation_details.get("end_date"):
logging.error(f"Failed to activate subscription after stars payment for user {message.from_user.id}")
return
referral_bonus_info = await referral_service.apply_referral_bonuses_for_payment(
session, message.from_user.id, months)
await session.commit()
applied_referee_days = referral_bonus_info.get("referee_bonus_applied_days") if referral_bonus_info else None
final_end = referral_bonus_info.get("referee_new_end_date") if referral_bonus_info else activation_details["end_date"]
current_lang = i18n_data.get("current_language", settings.DEFAULT_LANGUAGE)
i18n: JsonI18n = i18n_data.get("i18n_instance")
_ = lambda k, **kw: i18n.gettext(current_lang, k, **kw) if i18n else k
if applied_referee_days:
inviter_name_display = _("friend_placeholder")
db_user = await user_dal.get_user_by_id(session, message.from_user.id)
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 and inviter.first_name:
inviter_name_display = inviter.first_name
elif inviter and inviter.username:
inviter_name_display = f"@{inviter.username}"
success_msg = _("payment_successful_with_referral_bonus",
months=months,
base_end_date=activation_details["end_date"].strftime('%Y-%m-%d'),
bonus_days=applied_referee_days,
final_end_date=final_end.strftime('%Y-%m-%d'),
inviter_name=inviter_name_display)
else:
success_msg = _("payment_successful", months=months,
end_date=final_end.strftime('%Y-%m-%d'))
try:
await bot.send_message(message.from_user.id, success_msg)
except Exception as e_send:
logging.error(f"Failed to send stars payment success message: {e_send}")
@router.message(Command("connect"))
async def connect_command_handler(message: types.Message, i18n_data: dict,
settings: Settings,
View File
+127
View File
@@ -0,0 +1,127 @@
import logging
import hmac
import hashlib
import json
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 db.dal import payment_dal, user_dal, subscription_dal
async def tribute_webhook_route(request: web.Request):
bot: Bot = request.app['bot']
settings: Settings = request.app['settings']
i18n: JsonI18n = request.app['i18n']
async_session_factory: sessionmaker = request.app['async_session_factory']
panel_service: PanelApiService = request.app['panel_service']
subscription_service: SubscriptionService = request.app['subscription_service']
referral_service: ReferralService = request.app['referral_service']
raw_body = await request.read()
signature_header = request.headers.get('trbt-signature')
if settings.TRIBUTE_API_KEY:
if not signature_header:
return web.Response(status=403, text="no_signature")
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.Response(status=403, text="invalid_signature")
try:
payload = json.loads(raw_body.decode())
except Exception:
return web.Response(status=400, text="bad_request")
event_name = payload.get('name')
data = payload.get('payload', {})
user_id = data.get('telegram_user_id')
price_val = data.get('price')
if not user_id or price_val is None:
return web.Response(status=200, text="ok_missing_fields")
months_map = {int(v): m for m, v in settings.subscription_options.items()}
price_rub = price_val / 100
months = months_map.get(int(price_rub))
if not months:
logging.warning(f"Tribute webhook: price {price_val} not mapped to months")
return web.Response(status=200, text="ok_price_unmapped")
async with async_session_factory() as session:
if event_name == 'new_subscription':
payment_record = await payment_dal.create_payment_record(
session,
{
'user_id': user_id,
'amount': float(price_rub),
'currency': 'RUB',
'status': 'succeeded',
'description': 'Tribute subscription',
'subscription_duration_months': months,
'provider_payment_id': str(data.get('subscription_id')),
'provider': 'tribute',
},
)
activation_details = await subscription_service.activate_subscription(
session,
user_id,
months,
float(price_rub),
payment_record.payment_id,
provider='tribute',
)
referral_bonus = await referral_service.apply_referral_bonuses_for_payment(
session, user_id, months
)
await session.commit()
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 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 activation_details.get('end_date')
if final_end:
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 and inviter.first_name:
inviter_name_display = inviter.first_name
elif inviter and inviter.username:
inviter_name_display = f"@{inviter.username}"
success_msg = _("payment_successful_with_referral_bonus",
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)
else:
success_msg = _("payment_successful", months=months,
end_date=final_end.strftime('%Y-%m-%d'))
try:
await bot.send_message(user_id, success_msg)
except Exception as e:
logging.error(f"Failed to send Tribute payment success message to user {user_id}: {e}")
elif event_name == 'cancelled_subscription':
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 settings.DEFAULT_LANGUAGE
_ = lambda k, **kw: i18n.gettext(lang, k, **kw)
try:
await bot.send_message(user_id, _("subscription_cancelled_notification"))
except Exception as e:
logging.warning(f"Failed to notify user {user_id} about cancellation: {e}")
await subscription_dal.set_skip_notifications_for_provider(
session, user_id, 'tribute', False)
await session.commit()
else:
await session.commit()
return web.Response(status=200, text="ok")
+12 -8
View File
@@ -108,16 +108,20 @@ def get_subscription_options_keyboard(subscription_options: Dict[
return builder.as_markup()
def get_confirm_subscription_keyboard(months: int, price: float,
currency_symbol_val: str, lang: str,
i18n_instance) -> InlineKeyboardMarkup:
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) -> InlineKeyboardMarkup:
_ = lambda key, **kwargs: i18n_instance.gettext(lang, key, **kwargs)
builder = InlineKeyboardBuilder()
confirm_text = _(key="confirm_payment_button",
price=price,
currency_symbol=currency_symbol_val)
builder.button(text=confirm_text,
callback_data=f"confirm_sub:{months}:{price}")
if stars_price is not None:
builder.button(text=_("pay_with_stars_button"),
callback_data=f"pay_stars:{months}:{stars_price}")
if tribute_url:
builder.button(text=_("pay_with_tribute_button"), url=tribute_url)
builder.button(text=_("pay_with_yookassa_button"),
callback_data=f"pay_yk:{months}:{price}")
builder.button(text=_(key="cancel_button"),
callback_data="main_action:subscribe")
builder.adjust(1)
+9
View File
@@ -33,6 +33,7 @@ from bot.services.referral_service import ReferralService
from bot.services.promo_code_service import PromoCodeService
from bot.handlers.user import payment as user_payment_webhook_module
from bot.handlers.webhooks import tribute as tribute_webhook_module
class DBSessionMiddleware(BaseMiddleware):
@@ -327,6 +328,14 @@ async def run_bot(settings_param: Settings):
logging.info(
f"YooKassa webhook route configured at: [POST] {yk_path}")
tribute_path = settings_param.tribute_webhook_path
if tribute_path.startswith('/'):
app.router.add_post(
tribute_path,
tribute_webhook_module.tribute_webhook_route)
logging.info(
f"Tribute webhook route configured at: [POST] {tribute_path}")
web_app_runner = web.AppRunner(app)
await web_app_runner.setup()
site = web.TCPSite(web_app_runner,
+5 -2
View File
@@ -318,7 +318,8 @@ class SubscriptionService:
months: int,
payment_amount: float,
payment_db_id: int,
promo_code_id_from_payment: Optional[int] = None
promo_code_id_from_payment: Optional[int] = None,
provider: str = "yookassa"
) -> Optional[Dict[str, Any]]:
db_user = await user_dal.get_user_by_id(session, user_id)
@@ -386,6 +387,8 @@ class SubscriptionService:
"status_from_panel": "ACTIVE",
"traffic_limit_bytes":
self.settings.PANEL_USER_DEFAULT_TRAFFIC_BYTES,
"provider": provider,
"skip_notifications": provider == "tribute",
}
try:
new_or_updated_sub = await subscription_dal.upsert_subscription(
@@ -601,7 +604,7 @@ class SubscriptionService:
session, days_threshold)
results = []
for sub_model in subs_models_with_users:
if sub_model.user and sub_model.end_date:
if sub_model.user and sub_model.end_date and not sub_model.skip_notifications:
days_left = (sub_model.end_date - datetime.now(
timezone.utc)).total_seconds() / (24 * 3600)
results.append({