Refactor payment logic into dedicated services

This commit is contained in:
Machka Pasla
2025-06-24 01:35:20 +03:00
parent c7294ddaba
commit eea6afbcc7
11 changed files with 370 additions and 262 deletions
+20 -10
View File
@@ -31,20 +31,30 @@ YOOKASSA_PAYMENT_SUBJECT=payment
# If unset, bot will use polling while YooKassa uses webhooks. # If unset, bot will use polling while YooKassa uses webhooks.
TELEGRAM_WEBHOOK_BASE_URL=https://webhooks.yourdomain.tld TELEGRAM_WEBHOOK_BASE_URL=https://webhooks.yourdomain.tld
# Subscription Prices (integer values) # Payment Methods
PRICE_1_MONTH=150 YOOKASSA_ENABLED=True
PRICE_3_MONTHS=300 STARS_ENABLED=True
PRICE_6_MONTHS=500 TRIBUTE_ENABLED=True
PRICE_12_MONTHS=900
# Telegram Stars Prices (integer values) # Subscription Options
1_MONTH_ENABLED=True
RUB_PRICE_1_MONTH=150
STARS_PRICE_1_MONTH=0 STARS_PRICE_1_MONTH=0
STARS_PRICE_3_MONTHS=0
STARS_PRICE_6_MONTHS=0
STARS_PRICE_12_MONTHS=0
# Tribute Payment Links
TRIBUTE_LINK_1_MONTH= TRIBUTE_LINK_1_MONTH=
3_MONTHS_ENABLED=True
RUB_PRICE_3_MONTHS=300
STARS_PRICE_3_MONTHS=0
TRIBUTE_LINK_3_MONTHS= TRIBUTE_LINK_3_MONTHS=
6_MONTHS_ENABLED=True
RUB_PRICE_6_MONTHS=500
STARS_PRICE_6_MONTHS=0
TRIBUTE_LINK_6_MONTHS= TRIBUTE_LINK_6_MONTHS=
12_MONTHS_ENABLED=True
RUB_PRICE_12_MONTHS=900
STARS_PRICE_12_MONTHS=0
TRIBUTE_LINK_12_MONTHS= TRIBUTE_LINK_12_MONTHS=
# API key for verifying Tribute webhook signatures # API key for verifying Tribute webhook signatures
TRIBUTE_API_KEY= TRIBUTE_API_KEY=
+4 -1
View File
@@ -93,7 +93,10 @@ This Telegram bot is designed to automate the sale and management of subscriptio
* `YOOKASSA_PAYMENT_MODE`: e.g., `full_prepayment`. * `YOOKASSA_PAYMENT_MODE`: e.g., `full_prepayment`.
* `YOOKASSA_PAYMENT_SUBJECT`: e.g., `service`. * `YOOKASSA_PAYMENT_SUBJECT`: e.g., `service`.
* `TELEGRAM_WEBHOOK_BASE_URL`: (Optional) If you want Telegram updates via webhook. Can be the same as `YOOKASSA_WEBHOOK_BASE_URL`. If not set, the bot will use polling for Telegram updates. * `TELEGRAM_WEBHOOK_BASE_URL`: (Optional) If you want Telegram updates via webhook. Can be the same as `YOOKASSA_WEBHOOK_BASE_URL`. If not set, the bot will use polling for Telegram updates.
* `PRICE_X_MONTH`: Prices for different subscription durations. * **Payment Method Toggles:** `YOOKASSA_ENABLED`, `STARS_ENABLED`, `TRIBUTE_ENABLED`.
* **Subscription Options:** For each duration you can use variables like
`1_MONTH_ENABLED`, `RUB_PRICE_1_MONTH`, `STARS_PRICE_1_MONTH`, `TRIBUTE_LINK_1_MONTH`
(and corresponding variables for `3_MONTHS`, `6_MONTHS`, `12_MONTHS`).
* **Panel API Settings:** * **Panel API Settings:**
* `PANEL_API_URL`: Full URL to your Remnawave panel's API (e.g., `http://localhost:3000/api` or `https://panel.yourdomain.com/api`). * `PANEL_API_URL`: Full URL to your Remnawave panel's API (e.g., `http://localhost:3000/api` or `https://panel.yourdomain.com/api`).
* `PANEL_API_KEY`: API Key for authenticating with the Remnawave panel. * `PANEL_API_KEY`: API Key for authenticating with the Remnawave panel.
+1 -1
View File
@@ -17,7 +17,7 @@ from db.dal import payment_dal, user_dal
from bot.services.subscription_service import SubscriptionService from bot.services.subscription_service import SubscriptionService
from bot.services.referral_service import ReferralService from bot.services.referral_service import ReferralService
from bot.services.panel_api_service import PanelApiService from bot.services.panel_api_service import PanelApiService
from bot.services.payment_service import YooKassaService from bot.services.yookassa_service import YooKassaService
from bot.middlewares.i18n import JsonI18n from bot.middlewares.i18n import JsonI18n
from config.settings import Settings from config.settings import Settings
+11 -100
View File
@@ -7,11 +7,12 @@ from datetime import datetime, timezone
from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.ext.asyncio import AsyncSession
from config.settings import Settings from config.settings import Settings
from db.dal import payment_dal, user_dal from db.dal import payment_dal
from bot.keyboards.inline.user_keyboards import ( from bot.keyboards.inline.user_keyboards import (
get_subscription_options_keyboard, get_payment_method_keyboard, get_subscription_options_keyboard, get_payment_method_keyboard,
get_payment_url_keyboard, get_back_to_main_menu_markup) get_payment_url_keyboard, get_back_to_main_menu_markup)
from bot.services.payment_service import YooKassaService from bot.services.yookassa_service import YooKassaService
from bot.services.stars_service import StarsService
from bot.services.subscription_service import SubscriptionService from bot.services.subscription_service import SubscriptionService
from bot.services.panel_api_service import PanelApiService from bot.services.panel_api_service import PanelApiService
from bot.services.referral_service import ReferralService from bot.services.referral_service import ReferralService
@@ -111,6 +112,7 @@ async def select_subscription_period_callback_handler(
currency_symbol_val, currency_symbol_val,
current_lang, current_lang,
i18n, i18n,
settings,
) )
try: try:
@@ -128,7 +130,7 @@ async def select_subscription_period_callback_handler(
@router.callback_query(F.data.startswith("pay_stars:")) @router.callback_query(F.data.startswith("pay_stars:"))
async def pay_stars_callback_handler( async def pay_stars_callback_handler(
callback: types.CallbackQuery, settings: Settings, i18n_data: dict, callback: types.CallbackQuery, settings: Settings, i18n_data: dict,
session: AsyncSession, bot: Bot): session: AsyncSession, bot: Bot, stars_service: StarsService):
current_lang = i18n_data.get("current_language", settings.DEFAULT_LANGUAGE) current_lang = i18n_data.get("current_language", settings.DEFAULT_LANGUAGE)
i18n: Optional[JsonI18n] = i18n_data.get("i18n_instance") i18n: Optional[JsonI18n] = i18n_data.get("i18n_instance")
@@ -151,41 +153,9 @@ async def pay_stars_callback_handler(
user_id = callback.from_user.id user_id = callback.from_user.id
payment_description = get_text("payment_description_subscription", months=months) payment_description = get_text("payment_description_subscription", months=months)
payment_record_data = { payment_id = await stars_service.create_invoice(
"user_id": user_id, session, user_id, months, stars_price, payment_description)
"amount": float(stars_price), if payment_id is None:
"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.message.edit_text(get_text("error_payment_gateway"))
await callback.answer(get_text("error_try_again"), show_alert=True) await callback.answer(get_text("error_try_again"), show_alert=True)
return return
@@ -424,8 +394,7 @@ async def stars_pre_checkout_handler(pre_checkout_query: types.PreCheckoutQuery)
@router.message(F.successful_payment) @router.message(F.successful_payment)
async def stars_successful_payment_handler( async def stars_successful_payment_handler(
message: types.Message, settings: Settings, i18n_data: dict, message: types.Message, settings: Settings, i18n_data: dict,
session: AsyncSession, bot: Bot, panel_service: PanelApiService, session: AsyncSession, stars_service: StarsService):
subscription_service: SubscriptionService, referral_service: ReferralService):
sp = message.successful_payment sp = message.successful_payment
if not sp or sp.currency != "XTR": if not sp or sp.currency != "XTR":
return return
@@ -439,67 +408,9 @@ async def stars_successful_payment_handler(
logging.error(f"Invalid invoice payload for stars payment: {payload}") logging.error(f"Invalid invoice payload for stars payment: {payload}")
return return
provider_payment_id = sp.provider_payment_charge_id
stars_amount = sp.total_amount stars_amount = sp.total_amount
try: await stars_service.process_successful_payment(
await payment_dal.update_provider_payment_and_status( session, message, payment_db_id, months, stars_amount, i18n_data)
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 None)
if not final_end:
final_end = 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")) @router.message(Command("connect"))
+3 -124
View File
@@ -1,130 +1,9 @@
import logging
import hmac
import hashlib
import json
from aiohttp import web from aiohttp import web
from aiogram import Bot from bot.services.tribute_service import TributeService
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): async def tribute_webhook_route(request: web.Request):
bot: Bot = request.app['bot'] tribute_service: TributeService = request.app['tribute_service']
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() raw_body = await request.read()
signature_header = request.headers.get('trbt-signature') signature_header = request.headers.get('trbt-signature')
if settings.TRIBUTE_API_KEY: return await tribute_service.handle_webhook(raw_body, signature_header)
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 None)
if not final_end:
final_end = 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")
+4 -3
View File
@@ -112,14 +112,15 @@ def get_payment_method_keyboard(months: int, price: float,
tribute_url: Optional[str], tribute_url: Optional[str],
stars_price: Optional[int], stars_price: Optional[int],
currency_symbol_val: str, lang: str, currency_symbol_val: str, lang: str,
i18n_instance) -> InlineKeyboardMarkup: i18n_instance, settings: Settings) -> InlineKeyboardMarkup:
_ = lambda key, **kwargs: i18n_instance.gettext(lang, key, **kwargs) _ = lambda key, **kwargs: i18n_instance.gettext(lang, key, **kwargs)
builder = InlineKeyboardBuilder() builder = InlineKeyboardBuilder()
if stars_price is not None: if settings.STARS_ENABLED and stars_price is not None:
builder.button(text=_("pay_with_stars_button"), builder.button(text=_("pay_with_stars_button"),
callback_data=f"pay_stars:{months}:{stars_price}") callback_data=f"pay_stars:{months}:{stars_price}")
if tribute_url: if settings.TRIBUTE_ENABLED and tribute_url:
builder.button(text=_("pay_with_tribute_button"), url=tribute_url) builder.button(text=_("pay_with_tribute_button"), url=tribute_url)
if settings.YOOKASSA_ENABLED:
builder.button(text=_("pay_with_yookassa_button"), builder.button(text=_("pay_with_yookassa_button"),
callback_data=f"pay_yk:{months}:{price}") callback_data=f"pay_yk:{months}:{price}")
builder.button(text=_(key="cancel_button"), builder.button(text=_(key="cancel_button"),
+13 -1
View File
@@ -26,11 +26,13 @@ from bot.handlers.admin import admin_router_aggregate
from bot.filters.admin_filter import AdminFilter from bot.filters.admin_filter import AdminFilter
from bot.services.notification_service import schedule_subscription_notifications from bot.services.notification_service import schedule_subscription_notifications
from bot.services.payment_service import YooKassaService from bot.services.yookassa_service import YooKassaService
from bot.services.panel_api_service import PanelApiService from bot.services.panel_api_service import PanelApiService
from bot.services.subscription_service import SubscriptionService from bot.services.subscription_service import SubscriptionService
from bot.services.referral_service import ReferralService from bot.services.referral_service import ReferralService
from bot.services.promo_code_service import PromoCodeService from bot.services.promo_code_service import PromoCodeService
from bot.services.stars_service import StarsService
from bot.services.tribute_service import TributeService
from bot.handlers.user import payment as user_payment_webhook_module from bot.handlers.user import payment as user_payment_webhook_module
from bot.handlers.webhooks import tribute as tribute_webhook_module from bot.handlers.webhooks import tribute as tribute_webhook_module
@@ -238,6 +240,12 @@ async def run_bot(settings_param: Settings):
bot, i18n_instance) bot, i18n_instance)
promo_code_service = PromoCodeService(settings_param, subscription_service, promo_code_service = PromoCodeService(settings_param, subscription_service,
bot, i18n_instance) bot, i18n_instance)
stars_service = StarsService(bot, settings_param, i18n_instance,
subscription_service, referral_service)
tribute_service = TributeService(bot, settings_param, i18n_instance,
local_async_session_factory,
panel_service, subscription_service,
referral_service)
dp["i18n_instance"] = i18n_instance dp["i18n_instance"] = i18n_instance
dp["yookassa_service"] = yookassa_service dp["yookassa_service"] = yookassa_service
@@ -245,6 +253,8 @@ async def run_bot(settings_param: Settings):
dp["subscription_service"] = subscription_service dp["subscription_service"] = subscription_service
dp["referral_service"] = referral_service dp["referral_service"] = referral_service
dp["promo_code_service"] = promo_code_service dp["promo_code_service"] = promo_code_service
dp["stars_service"] = stars_service
dp["tribute_service"] = tribute_service
dp["async_session_factory"] = local_async_session_factory dp["async_session_factory"] = local_async_session_factory
dp.update.outer_middleware( dp.update.outer_middleware(
@@ -298,6 +308,8 @@ async def run_bot(settings_param: Settings):
app['subscription_service'] = subscription_service app['subscription_service'] = subscription_service
app['referral_service'] = referral_service app['referral_service'] = referral_service
app['panel_service'] = panel_service app['panel_service'] = panel_service
app['stars_service'] = stars_service
app['tribute_service'] = tribute_service
setup_application(app, dp, bot=bot) setup_application(app, dp, bot=bot)
+135
View File
@@ -0,0 +1,135 @@
import logging
from typing import Optional
from aiogram import Bot, types
from aiogram.types import LabeledPrice
from sqlalchemy.ext.asyncio import AsyncSession
from config.settings import Settings
from db.dal import payment_dal, user_dal
from .subscription_service import SubscriptionService
from .referral_service import ReferralService
from bot.middlewares.i18n import JsonI18n
class StarsService:
def __init__(self, bot: Bot, settings: Settings, i18n: JsonI18n,
subscription_service: SubscriptionService,
referral_service: ReferralService):
self.bot = bot
self.settings = settings
self.i18n = i18n
self.subscription_service = subscription_service
self.referral_service = referral_service
async def create_invoice(self, session: AsyncSession, user_id: int, months: int,
stars_price: int, description: str) -> Optional[int]:
payment_record_data = {
"user_id": user_id,
"amount": float(stars_price),
"currency": "XTR",
"status": "pending_stars",
"description": 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:
await session.rollback()
logging.error(f"Failed to create stars payment record: {e_db}",
exc_info=True)
return None
payload = f"{db_payment_record.payment_id}:{months}"
prices = [LabeledPrice(label=description, amount=stars_price)]
try:
await self.bot.send_invoice(
chat_id=user_id,
title=description,
description=description,
payload=payload,
provider_token="",
currency="XTR",
prices=prices,
)
return db_payment_record.payment_id
except Exception as e_inv:
logging.error(f"Failed to send Telegram Stars invoice: {e_inv}",
exc_info=True)
return None
async def process_successful_payment(self, session: AsyncSession,
message: types.Message,
payment_db_id: int,
months: int,
stars_amount: int,
i18n_data: dict) -> None:
try:
await payment_dal.update_provider_payment_and_status(
session, payment_db_id,
message.successful_payment.provider_payment_charge_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 self.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 = await self.referral_service.apply_referral_bonuses_for_payment(
session, message.from_user.id, months)
await session.commit()
applied_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["end_date"]
current_lang = i18n_data.get("current_language",
self.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_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_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 self.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}")
+148
View File
@@ -0,0 +1,148 @@
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 db.dal import payment_dal, user_dal, subscription_dal
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
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 None)
if not final_end:
final_end = 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")
+29 -20
View File
@@ -36,10 +36,19 @@ class Settings(BaseSettings):
TELEGRAM_WEBHOOK_BASE_URL: Optional[str] = None TELEGRAM_WEBHOOK_BASE_URL: Optional[str] = None
PRICE_1_MONTH: Optional[int] = Field(default=None) YOOKASSA_ENABLED: bool = Field(default=True)
PRICE_3_MONTHS: Optional[int] = Field(default=None) STARS_ENABLED: bool = Field(default=True)
PRICE_6_MONTHS: Optional[int] = Field(default=None) TRIBUTE_ENABLED: bool = Field(default=True)
PRICE_12_MONTHS: Optional[int] = Field(default=None)
MONTH_1_ENABLED: bool = Field(default=True, alias="1_MONTH_ENABLED")
MONTH_3_ENABLED: bool = Field(default=True, alias="3_MONTHS_ENABLED")
MONTH_6_ENABLED: bool = Field(default=True, alias="6_MONTHS_ENABLED")
MONTH_12_ENABLED: bool = Field(default=True, alias="12_MONTHS_ENABLED")
RUB_PRICE_1_MONTH: Optional[int] = Field(default=None)
RUB_PRICE_3_MONTHS: Optional[int] = Field(default=None)
RUB_PRICE_6_MONTHS: Optional[int] = Field(default=None)
RUB_PRICE_12_MONTHS: Optional[int] = Field(default=None)
STARS_PRICE_1_MONTH: Optional[int] = Field(default=None) STARS_PRICE_1_MONTH: Optional[int] = Field(default=None)
STARS_PRICE_3_MONTHS: Optional[int] = Field(default=None) STARS_PRICE_3_MONTHS: Optional[int] = Field(default=None)
@@ -170,27 +179,27 @@ class Settings(BaseSettings):
def subscription_options(self) -> Dict[int, float]: def subscription_options(self) -> Dict[int, float]:
options: Dict[int, float] = {} options: Dict[int, float] = {}
if self.PRICE_1_MONTH is not None: if self.MONTH_1_ENABLED and self.RUB_PRICE_1_MONTH is not None:
options[1] = float(self.PRICE_1_MONTH) options[1] = float(self.RUB_PRICE_1_MONTH)
if self.PRICE_3_MONTHS is not None: if self.MONTH_3_ENABLED and self.RUB_PRICE_3_MONTHS is not None:
options[3] = float(self.PRICE_3_MONTHS) options[3] = float(self.RUB_PRICE_3_MONTHS)
if self.PRICE_6_MONTHS is not None: if self.MONTH_6_ENABLED and self.RUB_PRICE_6_MONTHS is not None:
options[6] = float(self.PRICE_6_MONTHS) options[6] = float(self.RUB_PRICE_6_MONTHS)
if self.PRICE_12_MONTHS is not None: if self.MONTH_12_ENABLED and self.RUB_PRICE_12_MONTHS is not None:
options[12] = float(self.PRICE_12_MONTHS) options[12] = float(self.RUB_PRICE_12_MONTHS)
return options return options
@computed_field @computed_field
@property @property
def stars_subscription_options(self) -> Dict[int, int]: def stars_subscription_options(self) -> Dict[int, int]:
options: Dict[int, int] = {} options: Dict[int, int] = {}
if self.STARS_PRICE_1_MONTH is not None: if self.STARS_ENABLED and self.MONTH_1_ENABLED and self.STARS_PRICE_1_MONTH is not None:
options[1] = self.STARS_PRICE_1_MONTH options[1] = self.STARS_PRICE_1_MONTH
if self.STARS_PRICE_3_MONTHS is not None: if self.STARS_ENABLED and self.MONTH_3_ENABLED and self.STARS_PRICE_3_MONTHS is not None:
options[3] = self.STARS_PRICE_3_MONTHS options[3] = self.STARS_PRICE_3_MONTHS
if self.STARS_PRICE_6_MONTHS is not None: if self.STARS_ENABLED and self.MONTH_6_ENABLED and self.STARS_PRICE_6_MONTHS is not None:
options[6] = self.STARS_PRICE_6_MONTHS options[6] = self.STARS_PRICE_6_MONTHS
if self.STARS_PRICE_12_MONTHS is not None: if self.STARS_ENABLED and self.MONTH_12_ENABLED and self.STARS_PRICE_12_MONTHS is not None:
options[12] = self.STARS_PRICE_12_MONTHS options[12] = self.STARS_PRICE_12_MONTHS
return options return options
@@ -198,13 +207,13 @@ class Settings(BaseSettings):
@property @property
def tribute_payment_links(self) -> Dict[int, str]: def tribute_payment_links(self) -> Dict[int, str]:
links: Dict[int, str] = {} links: Dict[int, str] = {}
if self.TRIBUTE_LINK_1_MONTH: if self.TRIBUTE_ENABLED and self.MONTH_1_ENABLED and self.TRIBUTE_LINK_1_MONTH:
links[1] = self.TRIBUTE_LINK_1_MONTH links[1] = self.TRIBUTE_LINK_1_MONTH
if self.TRIBUTE_LINK_3_MONTHS: if self.TRIBUTE_ENABLED and self.MONTH_3_ENABLED and self.TRIBUTE_LINK_3_MONTHS:
links[3] = self.TRIBUTE_LINK_3_MONTHS links[3] = self.TRIBUTE_LINK_3_MONTHS
if self.TRIBUTE_LINK_6_MONTHS: if self.TRIBUTE_ENABLED and self.MONTH_6_ENABLED and self.TRIBUTE_LINK_6_MONTHS:
links[6] = self.TRIBUTE_LINK_6_MONTHS links[6] = self.TRIBUTE_LINK_6_MONTHS
if self.TRIBUTE_LINK_12_MONTHS: if self.TRIBUTE_ENABLED and self.MONTH_12_ENABLED and self.TRIBUTE_LINK_12_MONTHS:
links[12] = self.TRIBUTE_LINK_12_MONTHS links[12] = self.TRIBUTE_LINK_12_MONTHS
return links return links