Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
2215ee8ad2 | ||
|
|
1b6acff6d4 | ||
|
|
25d9cc7446 | ||
|
|
e1e73ec736 | ||
|
|
cc21089f18 | ||
|
|
46120210f1 | ||
|
|
df43283971 | ||
|
|
a51b20ee54 | ||
|
|
0915022b52 | ||
|
|
11f5ec4a5b | ||
|
|
d187a79be6 | ||
|
|
7b05ad4cb1 | ||
|
|
79ac5cb797 | ||
|
|
c208541525 | ||
|
|
2b592e8abe | ||
|
|
43de4cc08b | ||
|
|
9a6ff6f636 | ||
|
|
8113908a98 | ||
|
|
81155c9151 | ||
|
|
7fa670c1e8 | ||
|
|
d94430c602 | ||
|
|
c6292a1c8e | ||
|
|
d7d8409919 | ||
|
|
8f603ad51c | ||
|
|
6f3b727cc7 | ||
|
|
b7a723a088 | ||
|
|
807e3dadd3 | ||
|
|
f11ec6a676 | ||
|
|
d83f64c5fe | ||
|
|
72eaed991a | ||
|
|
868c48ccb9 | ||
|
|
2b855ed11b | ||
|
|
2bc7bea29d | ||
|
|
b6a2bf2329 | ||
|
|
ba8be7c3f9 | ||
|
|
cb464059d9 | ||
|
|
a5aecb0f86 | ||
|
|
cf7f0eae51 | ||
|
|
850f135bf8 | ||
|
|
793412c472 | ||
|
|
a510ab1021 | ||
|
|
50cea15c92 | ||
|
|
6b5828ea51 | ||
|
|
257597ccb7 | ||
|
|
dba82cfb97 | ||
|
|
e0e2cde9a7 | ||
|
|
5b98e1a40c | ||
|
|
0f69823192 | ||
|
|
38f3b1ad74 | ||
|
|
9b4dab84ad | ||
|
|
c607b6ef85 | ||
|
|
09f0de78c6 | ||
|
|
6a3b4a6947 | ||
|
|
d6f707d386 |
@@ -0,0 +1,92 @@
|
||||
# Git
|
||||
.git
|
||||
.gitignore
|
||||
.github
|
||||
.gitattributes
|
||||
LICENSE
|
||||
README.md
|
||||
|
||||
|
||||
# CI
|
||||
.codeclimate.yml
|
||||
.travis.yml
|
||||
.taskcluster.yml
|
||||
|
||||
# Docker
|
||||
docker-compose.yml
|
||||
Dockerfile
|
||||
.docker
|
||||
.dockerignore
|
||||
|
||||
# Byte-compiled / optimized / DLL files
|
||||
**/__pycache__/
|
||||
**/*.py[cod]
|
||||
|
||||
# C extensions
|
||||
*.so
|
||||
|
||||
# Distribution / packaging
|
||||
.Python
|
||||
env/
|
||||
build/
|
||||
develop-eggs/
|
||||
dist/
|
||||
downloads/
|
||||
eggs/
|
||||
lib/
|
||||
lib64/
|
||||
parts/
|
||||
sdist/
|
||||
var/
|
||||
*.egg-info/
|
||||
.installed.cfg
|
||||
*.egg
|
||||
|
||||
# PyInstaller
|
||||
# Usually these files are written by a python script from a template
|
||||
# before PyInstaller builds the exe, so as to inject date/other infos into it.
|
||||
*.manifest
|
||||
*.spec
|
||||
|
||||
# Installer logs
|
||||
pip-log.txt
|
||||
pip-delete-this-directory.txt
|
||||
|
||||
# Unit test / coverage reports
|
||||
htmlcov/
|
||||
.tox/
|
||||
.coverage
|
||||
.cache
|
||||
nosetests.xml
|
||||
coverage.xml
|
||||
|
||||
# Translations
|
||||
*.mo
|
||||
*.pot
|
||||
|
||||
# Django stuff:
|
||||
*.log
|
||||
|
||||
# Sphinx documentation
|
||||
docs/_build/
|
||||
|
||||
# PyBuilder
|
||||
target/
|
||||
|
||||
# Virtual environment
|
||||
.env*
|
||||
.venv/
|
||||
venv/
|
||||
|
||||
# PyCharm
|
||||
.idea
|
||||
|
||||
# Python mode for VIM
|
||||
.ropeproject
|
||||
**/.ropeproject
|
||||
|
||||
# Vim swap files
|
||||
**/*.swp
|
||||
|
||||
# VS Code
|
||||
.vscode/
|
||||
+59
-57
@@ -1,55 +1,55 @@
|
||||
# Telegram Bot Token and Admin IDs
|
||||
BOT_TOKEN=your_bot_token_here
|
||||
ADMIN_IDS=comma_separated_admin_ids
|
||||
BOT_TOKEN=your_bot_token_here # Telegram bot token
|
||||
ADMIN_IDS=comma_separated_admin_ids # Your telegram ID
|
||||
|
||||
POSTGRES_USER=postgres
|
||||
POSTGRES_PASSWORD=postgres
|
||||
POSTGRES_HOST=remnawave-tg-shop-db
|
||||
POSTGRES_PORT=5432
|
||||
POSTGRES_DB=postgres
|
||||
# PostgreSQL Database Connection Settings
|
||||
POSTGRES_USER=postgres # Database user name
|
||||
POSTGRES_PASSWORD=postgres # Database password
|
||||
POSTGRES_HOST=remnawave-tg-shop-db # Database container name
|
||||
POSTGRES_PORT=5432 # Port
|
||||
POSTGRES_DB=postgres # Database name
|
||||
|
||||
# Localization and Display
|
||||
DEFAULT_LANGUAGE="ru" # or "en"
|
||||
DEFAULT_CURRENCY_SYMBOL="RUB" # e.g., RUB, USD, EUR
|
||||
DEFAULT_LANGUAGE="ru" # or "en"
|
||||
DEFAULT_CURRENCY_SYMBOL="RUB" # e.g., RUB, USD, EUR
|
||||
|
||||
# External Links
|
||||
SUPPORT_LINK=https://t.me/your_support_link
|
||||
SERVER_STATUS_URL=https://status.yourdomain.tld/status/your_service
|
||||
TERMS_OF_SERVICE_URL=https://example.com/tos
|
||||
SUBSCRIPTION_MINI_APP_URL=
|
||||
START_COMMAND_DESCRIPTION=
|
||||
DISABLE_WELCOME_MESSAGE=
|
||||
SUPPORT_LINK=https://t.me/your_support_link # Link to the support chat
|
||||
SERVER_STATUS_URL=https://status.yourdomain.tld/status/your_service # Link to the server status page
|
||||
TERMS_OF_SERVICE_URL=https://example.com/tos # Link to the terms of service
|
||||
SUBSCRIPTION_MINI_APP_URL= # URL of the subscription mini-app
|
||||
START_COMMAND_DESCRIPTION= # Description of the /start command
|
||||
DISABLE_WELCOME_MESSAGE= # Disable the welcome message
|
||||
|
||||
# Webhook Base URL (used for Telegram and payment providers)
|
||||
WEBHOOK_BASE_URL=https://webhooks.yourdomain.tld
|
||||
|
||||
# YooKassa Payment Gateway Configuration
|
||||
YOOKASSA_SHOP_ID=your_shop_id
|
||||
YOOKASSA_SECRET_KEY=your_secret_key
|
||||
YOOKASSA_RETURN_URL=https://t.me/your_bot
|
||||
YOOKASSA_DEFAULT_RECEIPT_EMAIL=your_email@example.com
|
||||
YOOKASSA_VAT_CODE=1
|
||||
YOOKASSA_PAYMENT_MODE=full_prepayment
|
||||
YOOKASSA_PAYMENT_SUBJECT=payment
|
||||
YOOKASSA_SHOP_ID=your_shop_id # Your store ID in YooKassa
|
||||
YOOKASSA_SECRET_KEY=your_secret_key # Your secret key for YooKassa
|
||||
YOOKASSA_RETURN_URL=https://t.me/your_bot # URL to which the user will be returned after payment
|
||||
YOOKASSA_DEFAULT_RECEIPT_EMAIL=your_email@example.com # Default email for sending receipts
|
||||
YOOKASSA_VAT_CODE=1 # VAT code
|
||||
YOOKASSA_AUTOPAYMENTS_ENABLED=False # Auto-renew toggle
|
||||
|
||||
# CryptoBot Payment Gateway Configuration
|
||||
CRYPTOPAY_TOKEN=
|
||||
CRYPTOPAY_NETWORK=mainnet
|
||||
CRYPTOPAY_CURRENCY_TYPE=fiat
|
||||
CRYPTOPAY_ASSET=RUB
|
||||
CRYPTOPAY_TOKEN= # API token for CryptoPay
|
||||
CRYPTOPAY_NETWORK=mainnet # Network (mainnet or testnet)
|
||||
CRYPTOPAY_CURRENCY_TYPE=fiat # Currency type (fiat or crypto)
|
||||
CRYPTOPAY_ASSET=RUB # Asset, e.g., RUB, BTC, USDT
|
||||
|
||||
# Tribute Payment Gateway Configuration
|
||||
TRIBUTE_API_KEY= # API key for verifying Tribute webhook signatures
|
||||
TRIBUTE_SKIP_NOTIFICATIONS=True # Skip renewal notifications for Tribute payments
|
||||
TRIBUTE_SKIP_CANCELLATION_NOTIFICATIONS=False # Skip cancellation notifications for Tribute payments
|
||||
TRIBUTE_API_KEY= # API key for verifying Tribute webhook signatures
|
||||
TRIBUTE_SKIP_NOTIFICATIONS=True # Skip renewal notifications for Tribute payments
|
||||
TRIBUTE_SKIP_CANCELLATION_NOTIFICATIONS=False # Skip cancellation notifications for Tribute payments
|
||||
|
||||
# Payment Method Toggles
|
||||
YOOKASSA_ENABLED=True
|
||||
STARS_ENABLED=True
|
||||
TRIBUTE_ENABLED=True
|
||||
CRYPTOPAY_ENABLED=True
|
||||
YOOKASSA_ENABLED=True # Turn on YOOKASSA
|
||||
STARS_ENABLED=True # Turn on STARS
|
||||
TRIBUTE_ENABLED=True # Turn on TRIBUTE
|
||||
CRYPTOPAY_ENABLED=True # Turn on CRYPTOPAY
|
||||
|
||||
# Subscription Options
|
||||
# Subscription Options. Specify cost parameters or payment links here.
|
||||
1_MONTH_ENABLED=True
|
||||
RUB_PRICE_1_MONTH=150
|
||||
STARS_PRICE_1_MONTH=0
|
||||
@@ -71,59 +71,61 @@ STARS_PRICE_12_MONTHS=0
|
||||
TRIBUTE_LINK_12_MONTHS=
|
||||
|
||||
# Subscription Notifications
|
||||
SUBSCRIPTION_NOTIFICATIONS_ENABLED=True
|
||||
SUBSCRIPTION_NOTIFY_ON_EXPIRE=True
|
||||
SUBSCRIPTION_NOTIFY_AFTER_EXPIRE=True
|
||||
SUBSCRIPTION_NOTIFY_DAYS_BEFORE=3
|
||||
SUBSCRIPTION_NOTIFICATIONS_ENABLED=True # Enable subscription
|
||||
SUBSCRIPTION_NOTIFY_ON_EXPIRE=True # Notify on subscription
|
||||
SUBSCRIPTION_NOTIFY_AFTER_EXPIRE=True # Notify after
|
||||
SUBSCRIPTION_NOTIFY_DAYS_BEFORE=3 # Days before expiration to notify
|
||||
|
||||
|
||||
REFERRAL_ONE_BONUS_PER_REFEREE=False
|
||||
REFERRAL_ONE_BONUS_PER_REFEREE=False # Give a bonus only once per referee
|
||||
# Referral Bonus Days
|
||||
# Bonus for the inviting user
|
||||
REFERRAL_BONUS_DAYS_1_MONTH=3
|
||||
REFERRAL_BONUS_DAYS_3_MONTHS=7
|
||||
REFERRAL_BONUS_DAYS_6_MONTHS=15
|
||||
REFERRAL_BONUS_DAYS_12_MONTHS=30
|
||||
# Invited User Bonus
|
||||
REFEREE_BONUS_DAYS_1_MONTH=1
|
||||
REFEREE_BONUS_DAYS_3_MONTHS=3
|
||||
REFEREE_BONUS_DAYS_6_MONTHS=7
|
||||
REFEREE_BONUS_DAYS_12_MONTHS=15
|
||||
|
||||
# Panel API Configuration
|
||||
PANEL_API_URL=http://your_panel_api_url/api
|
||||
PANEL_API_KEY=your_panel_api_key
|
||||
PANEL_WEBHOOK_SECRET= # secret used to verify panel webhook signatures
|
||||
PANEL_API_URL=http://your_panel_api_url/api # URL of the panel API
|
||||
PANEL_API_KEY=your_panel_api_key # Panel API key
|
||||
PANEL_WEBHOOK_SECRET= # secret used to verify panel webhook signatures
|
||||
|
||||
# User traffic limits (applied for all users)
|
||||
# 0 means unlimited
|
||||
USER_TRAFFIC_LIMIT_GB=0
|
||||
USER_TRAFFIC_STRATEGY="NO_RESET"
|
||||
USER_TRAFFIC_LIMIT_GB=0 # Traffic limit for users (0 unlimited)
|
||||
USER_TRAFFIC_STRATEGY="NO_RESET" # Traffic reset strategy (NO_RESET, WEEK, MONTH)
|
||||
|
||||
# Default Internal Squads for Users (Optional, comma-separated UUIDs)
|
||||
USER_SQUAD_UUIDS=uuid1,uuid2,uuid3
|
||||
|
||||
# Trial Settings
|
||||
TRIAL_ENABLED=True
|
||||
TRIAL_DURATION_DAYS=5
|
||||
TRIAL_TRAFFIC_LIMIT_GB=0 # 0 for unlimited
|
||||
TRIAL_TRAFFIC_STRATEGY="NO_RESET"
|
||||
TRIAL_ENABLED=True # Enable the trial period
|
||||
TRIAL_DURATION_DAYS=5 # Duration of the trial period in days
|
||||
TRIAL_TRAFFIC_LIMIT_GB=0 # Traffic limit for the trial period (0 = unlimited)
|
||||
TRIAL_TRAFFIC_STRATEGY="NO_RESET" # Traffic reset strategy for the trial period (NO_RESET, WEEK, MONTH)
|
||||
|
||||
# Web Server Settings (for handling webhooks)
|
||||
WEB_SERVER_HOST="0.0.0.0"
|
||||
WEB_SERVER_PORT=8080
|
||||
|
||||
# Admin Panel Log Pagination
|
||||
LOGS_PAGE_SIZE=10
|
||||
LOGS_PAGE_SIZE=10 # Number of events in the log
|
||||
|
||||
# Admin Logging Configuration
|
||||
LOG_CHAT_ID=-1001234567890 # Telegram chat/group ID for admin notifications
|
||||
LOG_THREAD_ID= # Optional: Thread ID for supergroup messages
|
||||
LOG_NEW_USERS=True
|
||||
LOG_PAYMENTS=True
|
||||
LOG_PROMO_ACTIVATIONS=True
|
||||
LOG_TRIAL_ACTIVATIONS=True
|
||||
LOG_SUSPICIOUS_ACTIVITY=True
|
||||
LOG_CHAT_ID=-1001234567890 # Telegram chat/group ID for admin notifications
|
||||
LOG_THREAD_ID= # Optional: Thread ID for supergroup messages
|
||||
LOG_NEW_USERS=True # Log new user registrations
|
||||
LOG_PAYMENTS=True # Log payments
|
||||
LOG_PROMO_ACTIVATIONS=True # Log promo code activations
|
||||
LOG_TRIAL_ACTIVATIONS=True # Log trial activations
|
||||
LOG_SUSPICIOUS_ACTIVITY=True # Log suspicious activity
|
||||
|
||||
# Inline Mode Thumbnails
|
||||
# Embedded mode thumbnails. Please don't touch this if you don't know what it is.
|
||||
INLINE_REFERRAL_THUMBNAIL_URL=https://cdn-icons-png.flaticon.com/512/1077/1077114.png
|
||||
INLINE_USER_STATS_THUMBNAIL_URL=https://cdn-icons-png.flaticon.com/512/681/681494.png
|
||||
INLINE_FINANCIAL_STATS_THUMBNAIL_URL=https://cdn-icons-png.flaticon.com/512/2769/2769339.png
|
||||
|
||||
@@ -16,3 +16,4 @@ __pycache__/
|
||||
*.pid
|
||||
locales/ru_backup.json
|
||||
locales/en_backup.json
|
||||
db/models_old.py
|
||||
|
||||
@@ -0,0 +1,7 @@
|
||||
Copyright 2025 machka-pasla
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the “Software”), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions:
|
||||
|
||||
The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED “AS IS”, WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
||||
@@ -54,6 +54,15 @@ def build_core_services(
|
||||
settings_obj=settings,
|
||||
)
|
||||
|
||||
# Wire services that depend on each other
|
||||
try:
|
||||
# Attach YooKassa to subscription service for auto-renew charges
|
||||
setattr(subscription_service, "yookassa_service", yookassa_service)
|
||||
# Allow panel webhook to trigger renewals through subscription service
|
||||
setattr(panel_webhook_service, "subscription_service", subscription_service)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
return {
|
||||
"panel_service": panel_service,
|
||||
"subscription_service": subscription_service,
|
||||
|
||||
@@ -8,6 +8,7 @@ from . import statistics
|
||||
from . import sync_admin
|
||||
from . import logs_admin
|
||||
from . import payments
|
||||
from . import ads
|
||||
|
||||
admin_router_aggregate = Router(name="admin_features_router")
|
||||
|
||||
@@ -19,5 +20,6 @@ admin_router_aggregate.include_router(statistics.router)
|
||||
admin_router_aggregate.include_router(sync_admin.router)
|
||||
admin_router_aggregate.include_router(logs_admin.router)
|
||||
admin_router_aggregate.include_router(payments.router)
|
||||
admin_router_aggregate.include_router(ads.router)
|
||||
|
||||
__all__ = ("admin_router_aggregate", )
|
||||
|
||||
@@ -0,0 +1,239 @@
|
||||
import logging
|
||||
from aiogram import Router, F, types
|
||||
from aiogram.filters import StateFilter
|
||||
from aiogram.fsm.context import FSMContext
|
||||
from typing import Optional
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from config.settings import Settings
|
||||
from bot.middlewares.i18n import JsonI18n
|
||||
from db.dal import ad_dal
|
||||
from bot.states.admin_states import AdminStates
|
||||
|
||||
router = Router(name="admin_ads_router")
|
||||
|
||||
|
||||
PAGE_SIZE = 5
|
||||
|
||||
|
||||
@router.callback_query(F.data == "admin_action:ads")
|
||||
async def show_ads_menu(callback: types.CallbackQuery, settings: Settings, i18n_data: dict, session: AsyncSession):
|
||||
current_lang = i18n_data.get("current_language", settings.DEFAULT_LANGUAGE)
|
||||
i18n: Optional[JsonI18n] = i18n_data.get("i18n_instance")
|
||||
_ = lambda key, **kwargs: i18n.gettext(current_lang, key, **kwargs) if i18n else key
|
||||
|
||||
if not i18n or not callback.message:
|
||||
await callback.answer("Language error.", show_alert=True)
|
||||
return
|
||||
|
||||
totals = await ad_dal.get_totals(session)
|
||||
total_cost = totals.get("cost", 0.0)
|
||||
total_revenue = totals.get("revenue", 0.0)
|
||||
overview = _("admin_ads_overview", revenue=f"{total_revenue:.2f}", cost=f"{total_cost:.2f}")
|
||||
|
||||
total_count = await ad_dal.count_campaigns(session)
|
||||
if total_count == 0:
|
||||
text = overview + "\n\n" + _("admin_ads_empty")
|
||||
from bot.keyboards.inline.admin_keyboards import get_ads_menu_keyboard
|
||||
reply_markup = get_ads_menu_keyboard(i18n, current_lang)
|
||||
else:
|
||||
current_page = 0
|
||||
total_pages = max(1, (total_count + PAGE_SIZE - 1) // PAGE_SIZE)
|
||||
campaigns = await ad_dal.list_campaigns_paged(session, page=current_page, page_size=PAGE_SIZE)
|
||||
text = overview + "\n\n" + _("admin_ads_header")
|
||||
from bot.keyboards.inline.admin_keyboards import get_ads_list_keyboard
|
||||
reply_markup = get_ads_list_keyboard(i18n, current_lang, campaigns, current_page, total_pages)
|
||||
await callback.message.edit_text(text, reply_markup=reply_markup)
|
||||
try:
|
||||
await callback.answer()
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
|
||||
@router.callback_query(F.data.startswith("admin_ads:page:"))
|
||||
async def ads_list_pagination(callback: types.CallbackQuery, settings: Settings, i18n_data: dict, session: AsyncSession):
|
||||
current_lang = i18n_data.get("current_language", settings.DEFAULT_LANGUAGE)
|
||||
i18n: Optional[JsonI18n] = i18n_data.get("i18n_instance")
|
||||
_ = lambda key, **kwargs: i18n.gettext(current_lang, key, **kwargs) if i18n else key
|
||||
if not i18n or not callback.message:
|
||||
await callback.answer("Language error.", show_alert=True)
|
||||
return
|
||||
|
||||
try:
|
||||
page = int(callback.data.split(":")[2])
|
||||
except Exception:
|
||||
page = 0
|
||||
|
||||
totals = await ad_dal.get_totals(session)
|
||||
overview = _("admin_ads_overview", revenue=f"{totals.get('revenue', 0.0):.2f}", cost=f"{totals.get('cost', 0.0):.2f}")
|
||||
total_count = await ad_dal.count_campaigns(session)
|
||||
total_pages = max(1, (total_count + PAGE_SIZE - 1) // PAGE_SIZE)
|
||||
page = max(0, min(page, total_pages - 1))
|
||||
|
||||
campaigns = await ad_dal.list_campaigns_paged(session, page=page, page_size=PAGE_SIZE)
|
||||
text = overview + "\n\n" + _("admin_ads_header")
|
||||
from bot.keyboards.inline.admin_keyboards import get_ads_list_keyboard
|
||||
reply_markup = get_ads_list_keyboard(i18n, current_lang, campaigns, page, total_pages)
|
||||
try:
|
||||
await callback.message.edit_text(text, reply_markup=reply_markup)
|
||||
await callback.answer()
|
||||
except Exception as e:
|
||||
logging.error(f"Failed to paginate ads list: {e}")
|
||||
await callback.answer()
|
||||
|
||||
|
||||
@router.callback_query(F.data.startswith("admin_ads:card:"))
|
||||
async def show_ad_card(callback: types.CallbackQuery, settings: Settings, i18n_data: dict, session: AsyncSession):
|
||||
current_lang = i18n_data.get("current_language", settings.DEFAULT_LANGUAGE)
|
||||
i18n: Optional[JsonI18n] = i18n_data.get("i18n_instance")
|
||||
_ = lambda key, **kwargs: i18n.gettext(current_lang, key, **kwargs) if i18n else key
|
||||
if not i18n or not callback.message:
|
||||
await callback.answer("Language error.", show_alert=True)
|
||||
return
|
||||
|
||||
parts = callback.data.split(":")
|
||||
camp_id = int(parts[2])
|
||||
back_page = int(parts[3]) if len(parts) > 3 else 0
|
||||
|
||||
camp = await ad_dal.get_campaign_by_id(session, camp_id)
|
||||
if not camp:
|
||||
await callback.answer(_("admin_promo_not_found"), show_alert=True)
|
||||
return
|
||||
try:
|
||||
stats = await ad_dal.get_campaign_stats(session, camp_id)
|
||||
except Exception:
|
||||
stats = {"starts": 0, "trials": 0, "payers": 0, "revenue": 0.0}
|
||||
|
||||
text = _(
|
||||
"admin_ads_card",
|
||||
id=camp.ad_campaign_id,
|
||||
source=camp.source,
|
||||
start_param=camp.start_param,
|
||||
cost=f"{camp.cost:.2f}",
|
||||
active=_("csv_yes") if camp.is_active else _("csv_no"),
|
||||
starts=stats["starts"],
|
||||
trials=stats["trials"],
|
||||
payers=stats["payers"],
|
||||
revenue=f"{stats['revenue']:.2f}",
|
||||
)
|
||||
|
||||
from bot.keyboards.inline.admin_keyboards import get_ad_card_keyboard
|
||||
reply_markup = get_ad_card_keyboard(i18n, current_lang, camp.ad_campaign_id, back_page)
|
||||
try:
|
||||
await callback.message.edit_text(text, reply_markup=reply_markup, parse_mode="HTML")
|
||||
await callback.answer()
|
||||
except Exception as e:
|
||||
logging.error(f"Failed to show ad card: {e}")
|
||||
await callback.answer()
|
||||
|
||||
|
||||
@router.callback_query(F.data == "admin_action:ads_create")
|
||||
async def ads_create_start(callback: types.CallbackQuery, state: FSMContext, settings: Settings, i18n_data: dict):
|
||||
from bot.states.admin_states import AdminStates
|
||||
current_lang = i18n_data.get("current_language", settings.DEFAULT_LANGUAGE)
|
||||
i18n: Optional[JsonI18n] = i18n_data.get("i18n_instance")
|
||||
_ = lambda key, **kwargs: i18n.gettext(current_lang, key, **kwargs) if i18n else key
|
||||
|
||||
if not i18n or not callback.message:
|
||||
await callback.answer("Language error.", show_alert=True)
|
||||
return
|
||||
|
||||
await state.set_state(AdminStates.waiting_for_ad_source)
|
||||
await callback.message.edit_text(_("admin_ads_create_source_prompt"))
|
||||
try:
|
||||
await callback.answer()
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
|
||||
@router.message(
|
||||
StateFilter(
|
||||
AdminStates.waiting_for_ad_source,
|
||||
AdminStates.waiting_for_ad_start_param,
|
||||
AdminStates.waiting_for_ad_cost,
|
||||
),
|
||||
F.text,
|
||||
)
|
||||
async def ads_create_flow(message: types.Message, state: FSMContext, settings: Settings, i18n_data: dict, session: AsyncSession):
|
||||
current_state = await state.get_state()
|
||||
if current_state not in (
|
||||
AdminStates.waiting_for_ad_source.state,
|
||||
AdminStates.waiting_for_ad_start_param.state,
|
||||
AdminStates.waiting_for_ad_cost.state,
|
||||
):
|
||||
return
|
||||
|
||||
current_lang = i18n_data.get("current_language", settings.DEFAULT_LANGUAGE)
|
||||
i18n: Optional[JsonI18n] = i18n_data.get("i18n_instance")
|
||||
_ = lambda key, **kwargs: i18n.gettext(current_lang, key, **kwargs) if i18n else key
|
||||
|
||||
if current_state == AdminStates.waiting_for_ad_source.state:
|
||||
source = message.text.strip()
|
||||
if not source or len(source) > 64:
|
||||
await message.answer(_("admin_ads_invalid_source"))
|
||||
return
|
||||
await state.update_data(ad_source=source)
|
||||
await state.set_state(AdminStates.waiting_for_ad_start_param)
|
||||
await message.answer(_("admin_ads_create_start_param_prompt"))
|
||||
return
|
||||
|
||||
if current_state == AdminStates.waiting_for_ad_start_param.state:
|
||||
start_param = message.text.strip()
|
||||
# Allow alnum underscore dash only
|
||||
import re as _re
|
||||
if not _re.match(r"^[A-Za-z0-9_\-]{2,64}$", start_param):
|
||||
await message.answer(_("admin_ads_invalid_start_param"))
|
||||
return
|
||||
await state.update_data(ad_start_param=start_param)
|
||||
await state.set_state(AdminStates.waiting_for_ad_cost)
|
||||
await message.answer(_("admin_ads_create_cost_prompt"))
|
||||
return
|
||||
|
||||
if current_state == AdminStates.waiting_for_ad_cost.state:
|
||||
text = message.text.replace(",", ".").strip()
|
||||
try:
|
||||
cost = float(text)
|
||||
if cost < 0 or cost > 1e8:
|
||||
raise ValueError()
|
||||
except Exception:
|
||||
await message.answer(_("admin_ads_invalid_cost"))
|
||||
return
|
||||
|
||||
data = await state.get_data()
|
||||
try:
|
||||
campaign = await ad_dal.create_campaign(
|
||||
session,
|
||||
source=data.get("ad_source", "unknown"),
|
||||
start_param=data.get("ad_start_param", "NA"),
|
||||
cost=cost,
|
||||
)
|
||||
await session.commit()
|
||||
except ValueError as ve:
|
||||
await session.rollback()
|
||||
if str(ve) == "ad_campaign_start_param_exists":
|
||||
await message.answer(_("admin_ads_start_param_exists"))
|
||||
else:
|
||||
await message.answer(_("error_occurred_try_again"))
|
||||
return
|
||||
except Exception as e:
|
||||
await session.rollback()
|
||||
logging.error(f"Failed to create ad campaign: {e}", exc_info=True)
|
||||
await message.answer(_("error_occurred_try_again"))
|
||||
return
|
||||
|
||||
await state.clear()
|
||||
_ = lambda key, **kwargs: i18n.gettext(current_lang, key, **kwargs) if i18n else key
|
||||
await message.answer(
|
||||
_(
|
||||
"admin_ads_created_success",
|
||||
id=campaign.ad_campaign_id,
|
||||
source=campaign.source,
|
||||
start_param=campaign.start_param,
|
||||
cost=f"{campaign.cost:.2f}",
|
||||
)
|
||||
)
|
||||
# Offer back to ads menu
|
||||
from bot.keyboards.inline.admin_keyboards import get_ads_menu_keyboard
|
||||
await message.answer(_("admin_ads_back_to_menu_hint"), reply_markup=get_ads_menu_keyboard(i18n, current_lang))
|
||||
|
||||
|
||||
@@ -127,6 +127,12 @@ async def admin_panel_actions_callback_handler(
|
||||
from . import payments as admin_payments_handlers
|
||||
await admin_payments_handlers.view_payments_handler(
|
||||
callback, i18n_data, settings, session)
|
||||
elif action == "ads":
|
||||
from . import ads as admin_ads_handlers
|
||||
await admin_ads_handlers.show_ads_menu(callback, settings, i18n_data, session)
|
||||
elif action == "ads_create":
|
||||
from . import ads as admin_ads_handlers
|
||||
await admin_ads_handlers.ads_create_start(callback, state, settings, i18n_data)
|
||||
elif action == "main":
|
||||
try:
|
||||
await callback.message.edit_text(
|
||||
|
||||
@@ -144,7 +144,10 @@ async def perform_sync(panel_service: PanelApiService, session: AsyncSession,
|
||||
existing_user.first_name or "",
|
||||
existing_user.last_name or "",
|
||||
])
|
||||
if description_text.strip():
|
||||
# Update description only when it differs from the current one on panel
|
||||
current_panel_description = (panel_user_dict.get("description") or "").strip()
|
||||
desired_description = description_text.strip()
|
||||
if desired_description and desired_description != current_panel_description:
|
||||
await panel_service.update_user_details_on_panel(
|
||||
panel_uuid, {"description": description_text}
|
||||
)
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
from aiogram import Router
|
||||
|
||||
from . import start
|
||||
from . import subscription
|
||||
# TODO: after splitting subscription into a package, replace this import
|
||||
from .subscription import router as subscription_router
|
||||
from . import referral
|
||||
from . import promo_user
|
||||
from . import trial_handler
|
||||
@@ -11,5 +12,5 @@ user_router_aggregate = Router(name="user_router_aggregate")
|
||||
user_router_aggregate.include_router(promo_user.router)
|
||||
user_router_aggregate.include_router(trial_handler.router)
|
||||
user_router_aggregate.include_router(start.router)
|
||||
user_router_aggregate.include_router(subscription.router)
|
||||
user_router_aggregate.include_router(subscription_router)
|
||||
user_router_aggregate.include_router(referral.router)
|
||||
|
||||
+248
-43
@@ -12,7 +12,7 @@ from sqlalchemy.orm import sessionmaker
|
||||
from yookassa.domain.notification import WebhookNotification
|
||||
from yookassa.domain.models.amount import Amount as YooKassaAmount
|
||||
|
||||
from db.dal import payment_dal, user_dal
|
||||
from db.dal import payment_dal, user_dal, user_billing_dal
|
||||
|
||||
from bot.services.subscription_service import SubscriptionService
|
||||
from bot.services.referral_service import ReferralService
|
||||
@@ -27,6 +27,7 @@ payment_processing_lock = asyncio.Lock()
|
||||
|
||||
YOOKASSA_EVENT_PAYMENT_SUCCEEDED = 'payment.succeeded'
|
||||
YOOKASSA_EVENT_PAYMENT_CANCELED = 'payment.canceled'
|
||||
YOOKASSA_EVENT_PAYMENT_WAITING_FOR_CAPTURE = 'payment.waiting_for_capture'
|
||||
|
||||
|
||||
async def process_successful_payment(session: AsyncSession, bot: Bot,
|
||||
@@ -40,8 +41,13 @@ async def process_successful_payment(session: AsyncSession, bot: Bot,
|
||||
subscription_months_str = metadata.get("subscription_months")
|
||||
promo_code_id_str = metadata.get("promo_code_id")
|
||||
payment_db_id_str = metadata.get("payment_db_id")
|
||||
auto_renew_subscription_id_str = metadata.get(
|
||||
"auto_renew_for_subscription_id")
|
||||
|
||||
if not user_id_str or not subscription_months_str or not payment_db_id_str:
|
||||
# For auto-renew payments, payment_db_id may be absent. In that case,
|
||||
# we will create/ensure a payment record idempotently using provider payment id.
|
||||
if (not user_id_str or not subscription_months_str
|
||||
or (not payment_db_id_str and not auto_renew_subscription_id_str)):
|
||||
logging.error(
|
||||
f"Missing crucial metadata for payment: {payment_info_from_webhook.get('id')}, metadata: {metadata}"
|
||||
)
|
||||
@@ -51,7 +57,9 @@ async def process_successful_payment(session: AsyncSession, bot: Bot,
|
||||
try:
|
||||
user_id = int(user_id_str)
|
||||
subscription_months = int(subscription_months_str)
|
||||
payment_db_id = int(payment_db_id_str)
|
||||
payment_db_id = int(
|
||||
payment_db_id_str) if payment_db_id_str and payment_db_id_str.isdigit() else None
|
||||
is_auto_renew = bool(auto_renew_subscription_id_str and not payment_db_id)
|
||||
promo_code_id = int(
|
||||
promo_code_id_str
|
||||
) if promo_code_id_str and promo_code_id_str.isdigit() else None
|
||||
@@ -59,6 +67,44 @@ async def process_successful_payment(session: AsyncSession, bot: Bot,
|
||||
amount_data = payment_info_from_webhook.get("amount", {})
|
||||
payment_value = float(amount_data.get("value", 0.0))
|
||||
|
||||
# If this is an auto-renewal (no payment_db_id in metadata), ensure a payment record exists
|
||||
if payment_db_id is None and auto_renew_subscription_id_str:
|
||||
try:
|
||||
# Create/ensure provider payment by YooKassa payment id for idempotency
|
||||
yk_payment_id_from_hook = payment_info_from_webhook.get("id")
|
||||
from db.dal import payment_dal as _payment_dal
|
||||
ensured_payment = await _payment_dal.ensure_payment_with_provider_id(
|
||||
session,
|
||||
user_id=user_id,
|
||||
amount=payment_value,
|
||||
currency=amount_data.get("currency", settings.DEFAULT_CURRENCY_SYMBOL),
|
||||
months=subscription_months,
|
||||
description=payment_info_from_webhook.get(
|
||||
"description") or f"Auto-renewal for {subscription_months} months",
|
||||
provider="yookassa",
|
||||
provider_payment_id=yk_payment_id_from_hook,
|
||||
)
|
||||
payment_db_id = ensured_payment.payment_id
|
||||
# Also persist yookassa_payment_id field if not set yet
|
||||
try:
|
||||
await _payment_dal.update_payment_status_by_db_id(
|
||||
session,
|
||||
payment_db_id,
|
||||
payment_info_from_webhook.get("status", "succeeded"),
|
||||
yk_payment_id_from_hook,
|
||||
)
|
||||
except Exception:
|
||||
# Non-fatal; continue processing
|
||||
logging.exception(
|
||||
"Failed to backfill yookassa_payment_id for ensured auto-renew payment"
|
||||
)
|
||||
except Exception as e_ensure:
|
||||
logging.error(
|
||||
f"Failed to ensure payment record for auto-renew webhook (YK {payment_info_from_webhook.get('id')}): {e_ensure}",
|
||||
exc_info=True,
|
||||
)
|
||||
return
|
||||
|
||||
db_user = await user_dal.get_user_by_id(session, user_id)
|
||||
if not db_user:
|
||||
logging.error(
|
||||
@@ -89,6 +135,42 @@ async def process_successful_payment(session: AsyncSession, bot: Bot,
|
||||
|
||||
try:
|
||||
yk_payment_id_from_hook = payment_info_from_webhook.get("id")
|
||||
# Try to capture and save payment method for future charges if available
|
||||
try:
|
||||
payment_method = payment_info_from_webhook.get("payment_method")
|
||||
if getattr(settings, 'YOOKASSA_AUTOPAYMENTS_ENABLED', False) and isinstance(payment_method, dict) and payment_method.get("saved", False):
|
||||
pm_id = payment_method.get("id")
|
||||
pm_type = payment_method.get("type")
|
||||
title = payment_method.get("title")
|
||||
card = payment_method.get("card") or {}
|
||||
account_number = payment_method.get("account_number") or payment_method.get("account")
|
||||
display_network = None
|
||||
display_last4 = None
|
||||
# Build generic display for various instrument types
|
||||
if (pm_type or "").lower() in {"bank_card", "bank-card", "card"}:
|
||||
display_network = card.get("card_type") or title or "Card"
|
||||
display_last4 = card.get("last4")
|
||||
elif (pm_type or "").lower() in {"yoo_money", "yoomoney", "yoo-money", "wallet"}:
|
||||
# Normalize wallet display name to avoid leaking full account from title
|
||||
display_network = "YooMoney"
|
||||
if isinstance(account_number, str) and len(account_number) >= 4:
|
||||
display_last4 = account_number[-4:]
|
||||
else:
|
||||
display_last4 = None
|
||||
else:
|
||||
# Wallets, SBP, etc. — use provided title/type; no last4
|
||||
display_network = title or (pm_type.upper() if pm_type else "Payment method")
|
||||
display_last4 = None
|
||||
|
||||
await user_billing_dal.upsert_yk_payment_method(
|
||||
session,
|
||||
user_id=user_id,
|
||||
payment_method_id=pm_id,
|
||||
card_last4=display_last4,
|
||||
card_network=display_network,
|
||||
)
|
||||
except Exception:
|
||||
logging.exception("Failed to persist YooKassa payment method from webhook")
|
||||
updated_payment_record = await payment_dal.update_payment_status_by_db_id(
|
||||
session,
|
||||
payment_db_id=payment_db_id,
|
||||
@@ -137,56 +219,66 @@ async def process_successful_payment(session: AsyncSession, bot: Bot,
|
||||
applied_referee_bonus_days_from_referral = referral_bonus_info.get(
|
||||
"referee_bonus_applied_days")
|
||||
|
||||
# Use user's DB language for all user-facing messages
|
||||
user_lang = db_user.language_code if db_user and db_user.language_code else settings.DEFAULT_LANGUAGE
|
||||
_ = lambda key, **kwargs: i18n.gettext(user_lang, key, **kwargs)
|
||||
|
||||
config_link = activation_details.get("subscription_url") or _(
|
||||
"config_link_not_available"
|
||||
)
|
||||
|
||||
if applied_referee_bonus_days_from_referral and final_end_date_for_user:
|
||||
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}"
|
||||
|
||||
# For auto-renew charges, avoid re-sending config link; send concise message
|
||||
if is_auto_renew and final_end_date_for_user:
|
||||
details_message = _(
|
||||
"payment_successful_with_referral_bonus_full",
|
||||
months=subscription_months,
|
||||
base_end_date=base_subscription_end_date.strftime('%Y-%m-%d'),
|
||||
bonus_days=applied_referee_bonus_days_from_referral,
|
||||
final_end_date=final_end_date_for_user.strftime('%Y-%m-%d'),
|
||||
inviter_name=inviter_name_display,
|
||||
config_link=config_link,
|
||||
)
|
||||
elif applied_promo_bonus_days > 0 and final_end_date_for_user:
|
||||
details_message = _(
|
||||
"payment_successful_with_promo_full",
|
||||
months=subscription_months,
|
||||
bonus_days=applied_promo_bonus_days,
|
||||
end_date=final_end_date_for_user.strftime('%Y-%m-%d'),
|
||||
config_link=config_link,
|
||||
)
|
||||
elif final_end_date_for_user:
|
||||
details_message = _(
|
||||
"payment_successful_full",
|
||||
"yookassa_auto_renewal",
|
||||
months=subscription_months,
|
||||
end_date=final_end_date_for_user.strftime('%Y-%m-%d'),
|
||||
config_link=config_link,
|
||||
)
|
||||
details_markup = None
|
||||
else:
|
||||
logging.error(
|
||||
f"Critical error: final_end_date_for_user is None for user {user_id} after successful payment logic."
|
||||
config_link = activation_details.get("subscription_url") or _(
|
||||
"config_link_not_available"
|
||||
)
|
||||
details_message = _("payment_successful_error_details")
|
||||
|
||||
details_markup = get_connect_and_main_keyboard(
|
||||
user_lang, i18n, settings, config_link
|
||||
)
|
||||
if applied_referee_bonus_days_from_referral and final_end_date_for_user:
|
||||
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}"
|
||||
|
||||
details_message = _(
|
||||
"payment_successful_with_referral_bonus_full",
|
||||
months=subscription_months,
|
||||
base_end_date=base_subscription_end_date.strftime('%Y-%m-%d'),
|
||||
bonus_days=applied_referee_bonus_days_from_referral,
|
||||
final_end_date=final_end_date_for_user.strftime('%Y-%m-%d'),
|
||||
inviter_name=inviter_name_display,
|
||||
config_link=config_link,
|
||||
)
|
||||
elif applied_promo_bonus_days > 0 and final_end_date_for_user:
|
||||
details_message = _(
|
||||
"payment_successful_with_promo_full",
|
||||
months=subscription_months,
|
||||
bonus_days=applied_promo_bonus_days,
|
||||
end_date=final_end_date_for_user.strftime('%Y-%m-%d'),
|
||||
config_link=config_link,
|
||||
)
|
||||
elif final_end_date_for_user:
|
||||
details_message = _(
|
||||
"payment_successful_full",
|
||||
months=subscription_months,
|
||||
end_date=final_end_date_for_user.strftime('%Y-%m-%d'),
|
||||
config_link=config_link,
|
||||
)
|
||||
else:
|
||||
logging.error(
|
||||
f"Critical error: final_end_date_for_user is None for user {user_id} after successful payment logic."
|
||||
)
|
||||
details_message = _("payment_successful_error_details")
|
||||
|
||||
details_markup = get_connect_and_main_keyboard(
|
||||
user_lang, i18n, settings, config_link
|
||||
)
|
||||
try:
|
||||
await bot.send_message(
|
||||
user_id,
|
||||
@@ -313,6 +405,40 @@ async def yookassa_webhook_route(request: web.Request):
|
||||
)
|
||||
return web.Response(status=200, text="ok_error_no_metadata")
|
||||
|
||||
# Safely extract payment_method details (SDK objects may not have to_dict)
|
||||
pm_obj = getattr(payment_data_from_notification, 'payment_method', None)
|
||||
pm_dict = None
|
||||
if pm_obj is not None:
|
||||
try:
|
||||
card_obj = getattr(pm_obj, 'card', None)
|
||||
pm_dict = {
|
||||
"id": getattr(pm_obj, 'id', None),
|
||||
"type": getattr(pm_obj, 'type', None),
|
||||
"saved": bool(getattr(pm_obj, 'saved', False)),
|
||||
"title": getattr(pm_obj, 'title', None),
|
||||
"account_number": (
|
||||
getattr(pm_obj, 'account_number', None)
|
||||
if hasattr(pm_obj, 'account_number') else (
|
||||
getattr(pm_obj, 'account', None)
|
||||
if hasattr(pm_obj, 'account') else None
|
||||
)
|
||||
),
|
||||
"card": (
|
||||
{
|
||||
"first6": getattr(card_obj, 'first6', None),
|
||||
"last4": getattr(card_obj, 'last4', None),
|
||||
"expiry_month": getattr(card_obj, 'expiry_month', None),
|
||||
"expiry_year": getattr(card_obj, 'expiry_year', None),
|
||||
"card_type": getattr(card_obj, 'card_type', None),
|
||||
}
|
||||
if card_obj is not None
|
||||
else None
|
||||
),
|
||||
}
|
||||
except Exception:
|
||||
logging.exception("Failed to serialize YooKassa payment_method from webhook")
|
||||
pm_dict = None
|
||||
|
||||
payment_dict_for_processing = {
|
||||
"id":
|
||||
str(payment_data_from_notification.id),
|
||||
@@ -329,6 +455,7 @@ async def yookassa_webhook_route(request: web.Request):
|
||||
"description":
|
||||
str(payment_data_from_notification.description)
|
||||
if payment_data_from_notification.description else None,
|
||||
"payment_method": pm_dict,
|
||||
}
|
||||
|
||||
async with payment_processing_lock:
|
||||
@@ -354,6 +481,84 @@ async def yookassa_webhook_route(request: web.Request):
|
||||
session, bot, payment_dict_for_processing,
|
||||
i18n_instance, settings)
|
||||
await session.commit()
|
||||
elif notification_object.event == YOOKASSA_EVENT_PAYMENT_WAITING_FOR_CAPTURE:
|
||||
# Bind-only flow: save method and cancel auth if metadata has bind_only
|
||||
metadata = payment_dict_for_processing.get("metadata", {}) or {}
|
||||
if getattr(settings, 'YOOKASSA_AUTOPAYMENTS_ENABLED', False) and metadata.get("bind_only") == "1":
|
||||
try:
|
||||
user_id_str = metadata.get("user_id")
|
||||
if user_id_str and user_id_str.isdigit():
|
||||
user_id = int(user_id_str)
|
||||
payment_method = payment_dict_for_processing.get("payment_method")
|
||||
if isinstance(payment_method, dict) and payment_method.get("id"):
|
||||
pm_type = payment_method.get("type")
|
||||
title = payment_method.get("title")
|
||||
card = payment_method.get("card") or {}
|
||||
account_number = payment_method.get("account_number") or payment_method.get("account")
|
||||
display_network = None
|
||||
display_last4 = None
|
||||
if (pm_type or "").lower() in {"bank_card", "bank-card", "card"}:
|
||||
display_network = card.get("card_type") or title or "Card"
|
||||
display_last4 = card.get("last4")
|
||||
elif (pm_type or "").lower() in {"yoo_money", "yoomoney", "yoo-money", "wallet"}:
|
||||
# Normalize wallet display name to avoid leaking full account from title
|
||||
display_network = "YooMoney"
|
||||
if isinstance(account_number, str) and len(account_number) >= 4:
|
||||
display_last4 = account_number[-4:]
|
||||
else:
|
||||
display_last4 = None
|
||||
else:
|
||||
display_network = title or (pm_type.upper() if pm_type else "Payment method")
|
||||
display_last4 = None
|
||||
await user_billing_dal.upsert_yk_payment_method(
|
||||
session,
|
||||
user_id=user_id,
|
||||
payment_method_id=payment_method.get("id"),
|
||||
card_last4=display_last4,
|
||||
card_network=display_network,
|
||||
)
|
||||
await session.commit()
|
||||
# Save multi-card entry and mark default if first
|
||||
try:
|
||||
from db.dal import user_billing_dal as ub
|
||||
await ub.upsert_user_payment_method(
|
||||
session,
|
||||
user_id=user_id,
|
||||
provider_payment_method_id=payment_method.get("id"),
|
||||
provider="yookassa",
|
||||
card_last4=display_last4,
|
||||
card_network=display_network,
|
||||
set_default=True,
|
||||
)
|
||||
await session.commit()
|
||||
except Exception:
|
||||
await session.rollback()
|
||||
# Notify user about successful binding with Back button
|
||||
try:
|
||||
# Use user's DB language for bind success notification
|
||||
i18n_lang = settings.DEFAULT_LANGUAGE
|
||||
from db.dal import user_dal
|
||||
db_user = await user_dal.get_user_by_id(session, user_id)
|
||||
if db_user and db_user.language_code:
|
||||
i18n_lang = db_user.language_code
|
||||
_ = lambda key, **kwargs: i18n_instance.gettext(i18n_lang, key, **kwargs)
|
||||
from bot.keyboards.inline.user_keyboards import get_back_to_payment_methods_keyboard
|
||||
await bot.send_message(
|
||||
chat_id=user_id,
|
||||
text=_("payment_method_bound_success"),
|
||||
reply_markup=get_back_to_payment_methods_keyboard(i18n_lang, i18n_instance)
|
||||
)
|
||||
except Exception:
|
||||
pass
|
||||
# Attempt to cancel the authorization to avoid charge hold
|
||||
try:
|
||||
yk: YooKassaService = request.app.get('yookassa_service')
|
||||
if yk:
|
||||
await yk.cancel_payment(payment_dict_for_processing.get("id"))
|
||||
except Exception:
|
||||
logging.exception("Failed to cancel bind-only payment auth")
|
||||
except Exception:
|
||||
logging.exception("Failed to handle bind-only waiting_for_capture webhook")
|
||||
except Exception as e_webhook_db_processing:
|
||||
await session.rollback()
|
||||
logging.error(
|
||||
|
||||
@@ -118,6 +118,7 @@ async def send_main_menu(target_event: Union[types.Message,
|
||||
@router.message(CommandStart())
|
||||
@router.message(CommandStart(magic=F.args.regexp(r"^ref_(\d+)$").as_("ref_match")))
|
||||
@router.message(CommandStart(magic=F.args.regexp(r"^promo_(\w+)$").as_("promo_match")))
|
||||
@router.message(CommandStart(magic=F.args.regexp(r"^(?!ref_|promo_)([A-Za-z0-9_\-]{2,64})$").as_("ad_param_match")))
|
||||
async def start_command_handler(message: types.Message,
|
||||
state: FSMContext,
|
||||
settings: Settings,
|
||||
@@ -125,7 +126,8 @@ async def start_command_handler(message: types.Message,
|
||||
subscription_service: SubscriptionService,
|
||||
session: AsyncSession,
|
||||
ref_match: Optional[re.Match] = None,
|
||||
promo_match: Optional[re.Match] = None):
|
||||
promo_match: Optional[re.Match] = None,
|
||||
ad_param_match: Optional[re.Match] = None):
|
||||
await state.clear()
|
||||
current_lang = i18n_data.get("current_language", settings.DEFAULT_LANGUAGE)
|
||||
i18n: Optional[JsonI18n] = i18n_data.get("i18n_instance")
|
||||
@@ -137,6 +139,7 @@ async def start_command_handler(message: types.Message,
|
||||
|
||||
referred_by_user_id: Optional[int] = None
|
||||
promo_code_to_apply: Optional[str] = None
|
||||
ad_start_param: Optional[str] = None
|
||||
|
||||
if ref_match:
|
||||
potential_referrer_id = int(ref_match.group(1))
|
||||
@@ -145,6 +148,9 @@ async def start_command_handler(message: types.Message,
|
||||
elif promo_match:
|
||||
promo_code_to_apply = promo_match.group(1)
|
||||
logging.info(f"User {user_id} started with promo code: {promo_code_to_apply}")
|
||||
elif ad_param_match:
|
||||
ad_start_param = ad_param_match.group(1)
|
||||
logging.info(f"User {user_id} started with ad start param: {ad_start_param}")
|
||||
|
||||
db_user = await user_dal.get_user_by_id(session, user_id)
|
||||
if not db_user:
|
||||
@@ -217,6 +223,21 @@ async def start_command_handler(message: types.Message,
|
||||
f"Failed to update existing user {user_id} in session: {e_update}",
|
||||
exc_info=True)
|
||||
|
||||
# Attribute user to ad campaign if start param provided
|
||||
if ad_start_param:
|
||||
try:
|
||||
from db.dal import ad_dal as _ad_dal
|
||||
campaign = await _ad_dal.get_campaign_by_start_param(session, ad_start_param)
|
||||
if campaign and campaign.is_active:
|
||||
await _ad_dal.ensure_attribution(session, user_id=user_id, campaign_id=campaign.ad_campaign_id)
|
||||
await session.commit()
|
||||
except Exception as e_attr:
|
||||
logging.error(f"Failed to attribute user {user_id} to ad '{ad_start_param}': {e_attr}")
|
||||
try:
|
||||
await session.rollback()
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# Send welcome message if not disabled
|
||||
if not settings.DISABLE_WELCOME_MESSAGE:
|
||||
await message.answer(_(key="welcome", user_name=hd.quote(user.full_name)))
|
||||
|
||||
@@ -1,540 +0,0 @@
|
||||
import logging
|
||||
from aiogram import Router, F, types, Bot
|
||||
from aiogram.filters import Command
|
||||
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 bot.keyboards.inline.user_keyboards import (
|
||||
get_subscription_options_keyboard, get_payment_method_keyboard,
|
||||
get_payment_url_keyboard, get_back_to_main_menu_markup)
|
||||
from bot.services.yookassa_service import YooKassaService
|
||||
from bot.services.stars_service import StarsService
|
||||
from bot.services.crypto_pay_service import CryptoPayService
|
||||
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")
|
||||
|
||||
|
||||
async def display_subscription_options(event: Union[types.Message,
|
||||
types.CallbackQuery],
|
||||
i18n_data: dict, settings: Settings,
|
||||
session: AsyncSession):
|
||||
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:
|
||||
err_msg = "Language service error."
|
||||
if isinstance(event, types.CallbackQuery):
|
||||
try:
|
||||
await event.answer(err_msg, show_alert=True)
|
||||
except Exception:
|
||||
pass
|
||||
elif isinstance(event, types.Message):
|
||||
await event.answer(err_msg)
|
||||
return
|
||||
|
||||
currency_symbol_val = settings.DEFAULT_CURRENCY_SYMBOL
|
||||
text_content = get_text("select_subscription_period"
|
||||
) if settings.subscription_options else get_text(
|
||||
"no_subscription_options_available")
|
||||
|
||||
reply_markup = get_subscription_options_keyboard(
|
||||
settings.subscription_options, currency_symbol_val, current_lang, i18n
|
||||
) if settings.subscription_options else get_back_to_main_menu_markup(
|
||||
current_lang, i18n)
|
||||
|
||||
target_message_obj = event.message if isinstance(
|
||||
event, types.CallbackQuery) else event
|
||||
if not target_message_obj:
|
||||
if isinstance(event, types.CallbackQuery):
|
||||
try:
|
||||
await event.answer(get_text("error_occurred_try_again"),
|
||||
show_alert=True)
|
||||
except Exception:
|
||||
pass
|
||||
return
|
||||
|
||||
if isinstance(event, types.CallbackQuery):
|
||||
try:
|
||||
await target_message_obj.edit_text(text_content,
|
||||
reply_markup=reply_markup)
|
||||
except Exception:
|
||||
await target_message_obj.answer(text_content,
|
||||
reply_markup=reply_markup)
|
||||
try:
|
||||
await event.answer()
|
||||
except Exception:
|
||||
pass
|
||||
else:
|
||||
await target_message_obj.answer(text_content,
|
||||
reply_markup=reply_markup)
|
||||
|
||||
|
||||
@router.callback_query(F.data.startswith("subscribe_period:"))
|
||||
async def select_subscription_period_callback_handler(
|
||||
callback: types.CallbackQuery, settings: Settings, i18n_data: dict,
|
||||
session: AsyncSession):
|
||||
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:
|
||||
try:
|
||||
await callback.answer(get_text("error_occurred_try_again"),
|
||||
show_alert=True)
|
||||
except Exception:
|
||||
pass
|
||||
return
|
||||
|
||||
try:
|
||||
months = int(callback.data.split(":")[-1])
|
||||
except (ValueError, IndexError):
|
||||
logging.error(
|
||||
f"Invalid subscription period in callback_data: {callback.data}")
|
||||
try:
|
||||
await callback.answer(get_text("error_try_again"), show_alert=True)
|
||||
except Exception:
|
||||
pass
|
||||
return
|
||||
|
||||
price_rub = settings.subscription_options.get(months)
|
||||
if price_rub is None:
|
||||
logging.error(
|
||||
f"Price not found for {months} months subscription period in settings.subscription_options."
|
||||
)
|
||||
try:
|
||||
await callback.answer(get_text("error_try_again"), show_alert=True)
|
||||
except Exception:
|
||||
pass
|
||||
return
|
||||
|
||||
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,
|
||||
i18n,
|
||||
settings,
|
||||
)
|
||||
|
||||
try:
|
||||
await callback.message.edit_text(text_content,
|
||||
reply_markup=reply_markup)
|
||||
except Exception as e_edit:
|
||||
logging.warning(
|
||||
f"Edit message for payment method selection failed: {e_edit}. Sending new one."
|
||||
)
|
||||
await callback.message.answer(text_content,
|
||||
reply_markup=reply_markup)
|
||||
try:
|
||||
await callback.answer()
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
|
||||
@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, stars_service: StarsService):
|
||||
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:
|
||||
try:
|
||||
await callback.answer(get_text("error_occurred_try_again"), show_alert=True)
|
||||
except Exception:
|
||||
pass
|
||||
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}")
|
||||
try:
|
||||
await callback.answer(get_text("error_try_again"), show_alert=True)
|
||||
except Exception:
|
||||
pass
|
||||
return
|
||||
|
||||
user_id = callback.from_user.id
|
||||
payment_description = get_text("payment_description_subscription", months=months)
|
||||
|
||||
payment_id = await stars_service.create_invoice(
|
||||
session, user_id, months, stars_price, payment_description)
|
||||
if payment_id is None:
|
||||
await callback.message.edit_text(get_text("error_payment_gateway"))
|
||||
try:
|
||||
await callback.answer(get_text("error_try_again"), show_alert=True)
|
||||
except Exception:
|
||||
pass
|
||||
return
|
||||
|
||||
try:
|
||||
await callback.answer()
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
|
||||
@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)
|
||||
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:
|
||||
try:
|
||||
await callback.answer(get_text("error_occurred_try_again"),
|
||||
show_alert=True)
|
||||
except Exception:
|
||||
pass
|
||||
return
|
||||
|
||||
if not yookassa_service or not yookassa_service.configured:
|
||||
logging.error("YooKassa service is not configured or unavailable.")
|
||||
target_msg_edit = callback.message
|
||||
await target_msg_edit.edit_text(get_text("payment_service_unavailable")
|
||||
)
|
||||
try:
|
||||
await callback.answer(get_text("payment_service_unavailable_alert"),
|
||||
show_alert=True)
|
||||
except Exception:
|
||||
pass
|
||||
return
|
||||
|
||||
try:
|
||||
_, data_payload = callback.data.split(":", 1)
|
||||
months_str, price_str = data_payload.split(":")
|
||||
months = int(months_str)
|
||||
price_rub = float(price_str)
|
||||
except (ValueError, IndexError):
|
||||
logging.error(
|
||||
f"Invalid pay_yk data in callback: {callback.data}")
|
||||
try:
|
||||
await callback.answer(get_text("error_try_again"), show_alert=True)
|
||||
except Exception:
|
||||
pass
|
||||
return
|
||||
|
||||
user_id = callback.from_user.id
|
||||
|
||||
payment_description = get_text("payment_description_subscription",
|
||||
months=months)
|
||||
currency_code_for_yk = "RUB"
|
||||
|
||||
payment_record_data = {
|
||||
"user_id": user_id,
|
||||
"amount": price_rub,
|
||||
"currency": currency_code_for_yk,
|
||||
"status": "pending_yookassa",
|
||||
"description": payment_description,
|
||||
"subscription_duration_months": months,
|
||||
}
|
||||
db_payment_record = None
|
||||
try:
|
||||
db_payment_record = await payment_dal.create_payment_record(
|
||||
session, payment_record_data)
|
||||
await session.commit()
|
||||
logging.info(
|
||||
f"Payment record {db_payment_record.payment_id} created for user {user_id} with status 'pending_yookassa'."
|
||||
)
|
||||
except Exception as e_db_payment:
|
||||
await session.rollback()
|
||||
logging.error(
|
||||
f"Failed to create payment record in DB for user {user_id}: {e_db_payment}",
|
||||
exc_info=True)
|
||||
await callback.message.edit_text(
|
||||
get_text("error_creating_payment_record"))
|
||||
try:
|
||||
await callback.answer(get_text("error_try_again"), show_alert=True)
|
||||
except Exception:
|
||||
pass
|
||||
return
|
||||
|
||||
if not db_payment_record:
|
||||
await callback.message.edit_text(
|
||||
get_text("error_creating_payment_record"))
|
||||
try:
|
||||
await callback.answer(get_text("error_try_again"), show_alert=True)
|
||||
except Exception:
|
||||
pass
|
||||
return
|
||||
|
||||
yookassa_metadata = {
|
||||
"user_id": str(user_id),
|
||||
"subscription_months": str(months),
|
||||
"payment_db_id": str(db_payment_record.payment_id),
|
||||
}
|
||||
receipt_email_for_yk = settings.YOOKASSA_DEFAULT_RECEIPT_EMAIL
|
||||
|
||||
payment_response_yk = await yookassa_service.create_payment(
|
||||
amount=price_rub,
|
||||
currency=currency_code_for_yk,
|
||||
description=payment_description,
|
||||
metadata=yookassa_metadata,
|
||||
receipt_email=receipt_email_for_yk)
|
||||
|
||||
if payment_response_yk and payment_response_yk.get("confirmation_url"):
|
||||
try:
|
||||
await payment_dal.update_payment_status_by_db_id(
|
||||
session,
|
||||
payment_db_id=db_payment_record.payment_id,
|
||||
new_status=payment_response_yk.get("status", "pending"),
|
||||
yk_payment_id=payment_response_yk.get("id"))
|
||||
await session.commit()
|
||||
except Exception as e_db_update_ykid:
|
||||
await session.rollback()
|
||||
logging.error(
|
||||
f"Failed to update payment record {db_payment_record.payment_id} with YK ID: {e_db_update_ykid}",
|
||||
exc_info=True)
|
||||
await callback.message.edit_text(
|
||||
get_text("error_payment_gateway_link_failed"))
|
||||
try:
|
||||
await callback.answer(get_text("error_try_again"), show_alert=True)
|
||||
except Exception:
|
||||
pass
|
||||
return
|
||||
|
||||
await callback.message.edit_text(
|
||||
get_text(key="payment_link_message", months=months),
|
||||
reply_markup=get_payment_url_keyboard(
|
||||
payment_response_yk["confirmation_url"], current_lang, i18n),
|
||||
disable_web_page_preview=False)
|
||||
else:
|
||||
try:
|
||||
await payment_dal.update_payment_status_by_db_id(
|
||||
session, db_payment_record.payment_id, "failed_creation")
|
||||
await session.commit()
|
||||
except Exception as e_db_fail_create:
|
||||
await session.rollback()
|
||||
logging.error(
|
||||
f"Additionally failed to update payment record to 'failed_creation': {e_db_fail_create}",
|
||||
exc_info=True)
|
||||
|
||||
logging.error(
|
||||
f"Failed to create payment in YooKassa for user {user_id}, payment_db_id {db_payment_record.payment_id}. Response: {payment_response_yk}"
|
||||
)
|
||||
await callback.message.edit_text(get_text("error_payment_gateway"))
|
||||
|
||||
try:
|
||||
await callback.answer()
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
|
||||
@router.callback_query(F.data.startswith("pay_crypto:"))
|
||||
async def pay_crypto_callback_handler(
|
||||
callback: types.CallbackQuery, settings: Settings, i18n_data: dict,
|
||||
cryptopay_service: CryptoPayService, session: AsyncSession):
|
||||
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:
|
||||
try:
|
||||
await callback.answer(get_text("error_occurred_try_again"), show_alert=True)
|
||||
except Exception:
|
||||
pass
|
||||
return
|
||||
|
||||
if not cryptopay_service or not cryptopay_service.configured:
|
||||
await callback.message.edit_text(get_text("payment_service_unavailable"))
|
||||
try:
|
||||
await callback.answer(get_text("payment_service_unavailable_alert"), show_alert=True)
|
||||
except Exception:
|
||||
pass
|
||||
return
|
||||
|
||||
try:
|
||||
_, data_payload = callback.data.split(":", 1)
|
||||
months_str, amount_str = data_payload.split(":")
|
||||
months = int(months_str)
|
||||
amount_val = float(amount_str)
|
||||
except (ValueError, IndexError):
|
||||
logging.error(f"Invalid pay_crypto data in callback: {callback.data}")
|
||||
try:
|
||||
await callback.answer(get_text("error_try_again"), show_alert=True)
|
||||
except Exception:
|
||||
pass
|
||||
return
|
||||
|
||||
user_id = callback.from_user.id
|
||||
description = get_text("payment_description_subscription", months=months)
|
||||
|
||||
invoice_url = await cryptopay_service.create_invoice(
|
||||
session, user_id, months, amount_val, description)
|
||||
if invoice_url:
|
||||
await callback.message.edit_text(
|
||||
get_text("payment_link_message", months=months),
|
||||
reply_markup=get_payment_url_keyboard(invoice_url, current_lang, i18n),
|
||||
disable_web_page_preview=False,
|
||||
)
|
||||
else:
|
||||
await callback.message.edit_text(get_text("error_payment_gateway"))
|
||||
try:
|
||||
await callback.answer()
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
|
||||
@router.callback_query(F.data == "main_action:subscribe")
|
||||
async def reshow_subscription_options_callback(callback: types.CallbackQuery,
|
||||
i18n_data: dict,
|
||||
settings: Settings,
|
||||
session: AsyncSession):
|
||||
await display_subscription_options(callback, i18n_data, settings, session)
|
||||
|
||||
|
||||
async def my_subscription_command_handler(
|
||||
event: Union[types.Message, types.CallbackQuery],
|
||||
i18n_data: dict,
|
||||
settings: Settings,
|
||||
panel_service: PanelApiService,
|
||||
subscription_service: SubscriptionService,
|
||||
session: AsyncSession,
|
||||
bot: Bot
|
||||
):
|
||||
target = event.message if isinstance(event, types.CallbackQuery) else event
|
||||
current_lang = i18n_data.get("current_language", settings.DEFAULT_LANGUAGE)
|
||||
i18n: JsonI18n = i18n_data.get("i18n_instance")
|
||||
get_text = lambda key, **kw: i18n.gettext(current_lang, key, **kw)
|
||||
|
||||
if not i18n or not target:
|
||||
if isinstance(event, types.Message):
|
||||
await event.answer(get_text("error_occurred_try_again"))
|
||||
return
|
||||
|
||||
if not panel_service or not subscription_service:
|
||||
await target.answer(get_text("error_service_unavailable"))
|
||||
return
|
||||
|
||||
active = await subscription_service.get_active_subscription_details(session, event.from_user.id)
|
||||
|
||||
if not active:
|
||||
text = get_text("subscription_not_active")
|
||||
|
||||
buy_button = InlineKeyboardButton(
|
||||
text=get_text("menu_subscribe_inline", default="Купить"),
|
||||
callback_data="main_action:subscribe"
|
||||
)
|
||||
back_markup = get_back_to_main_menu_markup(current_lang, i18n)
|
||||
|
||||
kb = InlineKeyboardMarkup(
|
||||
inline_keyboard=[
|
||||
[buy_button],
|
||||
*back_markup.inline_keyboard
|
||||
]
|
||||
)
|
||||
|
||||
if isinstance(event, types.CallbackQuery):
|
||||
try:
|
||||
await event.answer()
|
||||
except Exception:
|
||||
pass
|
||||
try:
|
||||
await event.message.edit_text(text, reply_markup=kb)
|
||||
except:
|
||||
await event.message.answer(text, reply_markup=kb)
|
||||
else:
|
||||
await event.answer(text, reply_markup=kb)
|
||||
return
|
||||
|
||||
end_date = active.get("end_date")
|
||||
days_left = (
|
||||
(end_date.date() - datetime.now().date()).days
|
||||
if end_date else 0
|
||||
)
|
||||
text = get_text(
|
||||
"my_subscription_details",
|
||||
end_date=end_date.strftime("%Y-%m-%d") if end_date else "N/A",
|
||||
days_left=max(0, days_left),
|
||||
status=active.get("status_from_panel", get_text("status_active")).capitalize(),
|
||||
config_link=active.get("config_link") or get_text("config_link_not_available"),
|
||||
traffic_limit=(
|
||||
f"{active['traffic_limit_bytes'] / 2**30:.2f} GB"
|
||||
if active.get("traffic_limit_bytes")
|
||||
else get_text("traffic_unlimited")
|
||||
),
|
||||
traffic_used=(
|
||||
f"{active['traffic_used_bytes'] / 2**30:.2f} GB"
|
||||
if active.get("traffic_used_bytes") is not None
|
||||
else get_text("traffic_na")
|
||||
)
|
||||
)
|
||||
markup = get_back_to_main_menu_markup(current_lang, i18n)
|
||||
|
||||
if isinstance(event, types.CallbackQuery):
|
||||
try:
|
||||
await event.answer()
|
||||
except Exception:
|
||||
pass
|
||||
try:
|
||||
await event.message.edit_text(text, reply_markup=markup, parse_mode="HTML", disable_web_page_preview=True)
|
||||
except:
|
||||
await bot.send_message(chat_id=target.chat.id, text=text, reply_markup=markup, parse_mode="HTML", disable_web_page_preview=True)
|
||||
else:
|
||||
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, stars_service: StarsService):
|
||||
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
|
||||
|
||||
stars_amount = sp.total_amount
|
||||
await stars_service.process_successful_payment(
|
||||
session, message, payment_db_id, months, stars_amount, i18n_data)
|
||||
|
||||
|
||||
@router.message(Command("connect"))
|
||||
async def connect_command_handler(message: types.Message, i18n_data: dict,
|
||||
settings: Settings,
|
||||
panel_service: PanelApiService,
|
||||
subscription_service: SubscriptionService,
|
||||
session: AsyncSession, bot: Bot):
|
||||
logging.info(f"User {message.from_user.id} used /connect command.")
|
||||
await my_subscription_command_handler(message, i18n_data, settings,
|
||||
panel_service, subscription_service,
|
||||
session, bot)
|
||||
@@ -0,0 +1,17 @@
|
||||
from aiogram import Router
|
||||
|
||||
from . import core
|
||||
from . import payments
|
||||
from . import payment_methods
|
||||
|
||||
router = Router(name="user_subscription_router")
|
||||
|
||||
# Include sub-routers
|
||||
router.include_router(core.router)
|
||||
router.include_router(payments.router)
|
||||
router.include_router(payment_methods.router)
|
||||
|
||||
# Re-export commonly used entrypoints for backward compatibility
|
||||
from .core import display_subscription_options, my_subscription_command_handler # noqa: E402,F401
|
||||
|
||||
|
||||
@@ -0,0 +1,354 @@
|
||||
import logging
|
||||
from aiogram import Router, F, types, Bot
|
||||
from aiogram.filters import Command
|
||||
from aiogram.types import InlineKeyboardButton, InlineKeyboardMarkup, WebAppInfo
|
||||
from typing import Optional, Union
|
||||
from datetime import datetime
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from sqlalchemy.future import select
|
||||
|
||||
from config.settings import Settings
|
||||
from bot.keyboards.inline.user_keyboards import (
|
||||
get_subscription_options_keyboard,
|
||||
get_back_to_main_menu_markup,
|
||||
get_autorenew_confirm_keyboard,
|
||||
)
|
||||
from bot.services.subscription_service import SubscriptionService
|
||||
from bot.services.panel_api_service import PanelApiService
|
||||
from bot.middlewares.i18n import JsonI18n
|
||||
from db.dal import subscription_dal
|
||||
from db.models import Subscription
|
||||
|
||||
router = Router(name="user_subscription_core_router")
|
||||
|
||||
|
||||
async def display_subscription_options(event: Union[types.Message, types.CallbackQuery], i18n_data: dict, settings: Settings, session: AsyncSession):
|
||||
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:
|
||||
err_msg = "Language service error."
|
||||
if isinstance(event, types.CallbackQuery):
|
||||
try:
|
||||
await event.answer(err_msg, show_alert=True)
|
||||
except Exception:
|
||||
pass
|
||||
elif isinstance(event, types.Message):
|
||||
await event.answer(err_msg)
|
||||
return
|
||||
|
||||
currency_symbol_val = settings.DEFAULT_CURRENCY_SYMBOL
|
||||
text_content = get_text("select_subscription_period") if settings.subscription_options else get_text("no_subscription_options_available")
|
||||
|
||||
reply_markup = (
|
||||
get_subscription_options_keyboard(settings.subscription_options, currency_symbol_val, current_lang, i18n)
|
||||
if settings.subscription_options
|
||||
else get_back_to_main_menu_markup(current_lang, i18n)
|
||||
)
|
||||
|
||||
target_message_obj = event.message if isinstance(event, types.CallbackQuery) else event
|
||||
if not target_message_obj:
|
||||
if isinstance(event, types.CallbackQuery):
|
||||
try:
|
||||
await event.answer(get_text("error_occurred_try_again"), show_alert=True)
|
||||
except Exception:
|
||||
pass
|
||||
return
|
||||
|
||||
if isinstance(event, types.CallbackQuery):
|
||||
try:
|
||||
await target_message_obj.edit_text(text_content, reply_markup=reply_markup)
|
||||
except Exception:
|
||||
await target_message_obj.answer(text_content, reply_markup=reply_markup)
|
||||
try:
|
||||
await event.answer()
|
||||
except Exception:
|
||||
pass
|
||||
else:
|
||||
await target_message_obj.answer(text_content, reply_markup=reply_markup)
|
||||
|
||||
|
||||
@router.callback_query(F.data == "main_action:subscribe")
|
||||
async def reshow_subscription_options_callback(callback: types.CallbackQuery, i18n_data: dict, settings: Settings, session: AsyncSession):
|
||||
await display_subscription_options(callback, i18n_data, settings, session)
|
||||
|
||||
|
||||
async def my_subscription_command_handler(
|
||||
event: Union[types.Message, types.CallbackQuery],
|
||||
i18n_data: dict,
|
||||
settings: Settings,
|
||||
panel_service: PanelApiService,
|
||||
subscription_service: SubscriptionService,
|
||||
session: AsyncSession,
|
||||
bot: Bot,
|
||||
):
|
||||
target = event.message if isinstance(event, types.CallbackQuery) else event
|
||||
current_lang = i18n_data.get("current_language", settings.DEFAULT_LANGUAGE)
|
||||
i18n: JsonI18n = i18n_data.get("i18n_instance")
|
||||
get_text = lambda key, **kw: i18n.gettext(current_lang, key, **kw)
|
||||
|
||||
if not i18n or not target:
|
||||
if isinstance(event, types.Message):
|
||||
await event.answer(get_text("error_occurred_try_again"))
|
||||
return
|
||||
|
||||
if not panel_service or not subscription_service:
|
||||
await target.answer(get_text("error_service_unavailable"))
|
||||
return
|
||||
|
||||
active = await subscription_service.get_active_subscription_details(session, event.from_user.id)
|
||||
|
||||
if not active:
|
||||
text = get_text("subscription_not_active")
|
||||
|
||||
buy_button = InlineKeyboardButton(
|
||||
text=get_text("menu_subscribe_inline", default="Купить"), callback_data="main_action:subscribe"
|
||||
)
|
||||
back_markup = get_back_to_main_menu_markup(current_lang, i18n)
|
||||
|
||||
kb = InlineKeyboardMarkup(inline_keyboard=[[buy_button], *back_markup.inline_keyboard])
|
||||
|
||||
if isinstance(event, types.CallbackQuery):
|
||||
try:
|
||||
await event.answer()
|
||||
except Exception:
|
||||
pass
|
||||
try:
|
||||
await event.message.edit_text(text, reply_markup=kb)
|
||||
except Exception:
|
||||
await event.message.answer(text, reply_markup=kb)
|
||||
else:
|
||||
await event.answer(text, reply_markup=kb)
|
||||
return
|
||||
|
||||
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",
|
||||
days_left=max(0, days_left),
|
||||
status=active.get("status_from_panel", get_text("status_active")).capitalize(),
|
||||
config_link=active.get("config_link") or get_text("config_link_not_available"),
|
||||
traffic_limit=(f"{active['traffic_limit_bytes'] / 2**30:.2f} GB" if active.get("traffic_limit_bytes") else get_text("traffic_unlimited")),
|
||||
traffic_used=(
|
||||
f"{active['traffic_used_bytes'] / 2**30:.2f} GB" if active.get("traffic_used_bytes") is not None else get_text("traffic_na")
|
||||
),
|
||||
)
|
||||
|
||||
base_markup = get_back_to_main_menu_markup(current_lang, i18n)
|
||||
kb = base_markup.inline_keyboard
|
||||
try:
|
||||
local_sub = await subscription_dal.get_active_subscription_by_user_id(session, event.from_user.id)
|
||||
# Build rows to prepend above the base "back" markup
|
||||
prepend_rows = []
|
||||
|
||||
# 1) Mini-app connect button on top if enabled
|
||||
if settings.SUBSCRIPTION_MINI_APP_URL:
|
||||
prepend_rows.append([
|
||||
InlineKeyboardButton(
|
||||
text=get_text("connect_button"),
|
||||
web_app=WebAppInfo(url=settings.SUBSCRIPTION_MINI_APP_URL),
|
||||
)
|
||||
])
|
||||
|
||||
# 2) Auto-renew toggle (if supported and not tribute)
|
||||
if local_sub and local_sub.provider != "tribute" and getattr(settings, 'YOOKASSA_AUTOPAYMENTS_ENABLED', False):
|
||||
toggle_text = (
|
||||
get_text("autorenew_disable_button") if local_sub.auto_renew_enabled else get_text("autorenew_enable_button")
|
||||
)
|
||||
prepend_rows.append([
|
||||
InlineKeyboardButton(
|
||||
text=toggle_text,
|
||||
callback_data=f"toggle_autorenew:{local_sub.subscription_id}:{1 if not local_sub.auto_renew_enabled else 0}",
|
||||
)
|
||||
])
|
||||
|
||||
# 3) Payment methods management (when autopayments enabled)
|
||||
if getattr(settings, 'YOOKASSA_AUTOPAYMENTS_ENABLED', False):
|
||||
prepend_rows.append([
|
||||
InlineKeyboardButton(text=get_text("payment_methods_manage_button"), callback_data="pm:manage")
|
||||
])
|
||||
|
||||
if prepend_rows:
|
||||
kb = prepend_rows + kb
|
||||
except Exception:
|
||||
pass
|
||||
markup = InlineKeyboardMarkup(inline_keyboard=kb)
|
||||
|
||||
if isinstance(event, types.CallbackQuery):
|
||||
try:
|
||||
await event.answer()
|
||||
except Exception:
|
||||
pass
|
||||
try:
|
||||
await event.message.edit_text(text + tribute_hint, 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,
|
||||
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)
|
||||
|
||||
|
||||
@router.callback_query(F.data.startswith("toggle_autorenew:"))
|
||||
async def toggle_autorenew_handler(
|
||||
callback: types.CallbackQuery,
|
||||
settings: Settings,
|
||||
i18n_data: dict,
|
||||
session: AsyncSession,
|
||||
subscription_service: SubscriptionService,
|
||||
panel_service: PanelApiService,
|
||||
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
|
||||
|
||||
try:
|
||||
_, payload = callback.data.split(":", 1)
|
||||
sub_id_str, enable_str = payload.split(":")
|
||||
sub_id = int(sub_id_str)
|
||||
enable = bool(int(enable_str))
|
||||
except Exception:
|
||||
try:
|
||||
await callback.answer(get_text("error_try_again"), show_alert=True)
|
||||
except Exception:
|
||||
pass
|
||||
return
|
||||
|
||||
sub = await session.get(Subscription, sub_id)
|
||||
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)
|
||||
return
|
||||
|
||||
# Show confirmation popup and inline buttons
|
||||
confirm_text = get_text("autorenew_confirm_enable") if enable else get_text("autorenew_confirm_disable")
|
||||
kb = get_autorenew_confirm_keyboard(enable, sub.subscription_id, current_lang, i18n)
|
||||
try:
|
||||
await callback.message.edit_text(confirm_text, reply_markup=kb)
|
||||
except Exception:
|
||||
try:
|
||||
await callback.message.answer(confirm_text, reply_markup=kb)
|
||||
except Exception:
|
||||
pass
|
||||
try:
|
||||
await callback.answer()
|
||||
except Exception:
|
||||
pass
|
||||
return
|
||||
|
||||
|
||||
@router.callback_query(F.data.startswith("autorenew:confirm:"))
|
||||
async def confirm_autorenew_handler(
|
||||
callback: types.CallbackQuery,
|
||||
settings: Settings,
|
||||
i18n_data: dict,
|
||||
session: AsyncSession,
|
||||
subscription_service: SubscriptionService,
|
||||
panel_service: PanelApiService,
|
||||
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
|
||||
|
||||
try:
|
||||
_, _, sub_id_str, enable_str = callback.data.split(":", 3)
|
||||
sub_id = int(sub_id_str)
|
||||
enable = bool(int(enable_str))
|
||||
except Exception:
|
||||
try:
|
||||
await callback.answer(get_text("error_try_again"), show_alert=True)
|
||||
except Exception:
|
||||
pass
|
||||
return
|
||||
|
||||
sub = await session.get(Subscription, sub_id)
|
||||
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)
|
||||
return
|
||||
|
||||
await subscription_dal.update_subscription(session, sub.subscription_id, {"auto_renew_enabled": enable})
|
||||
await session.commit()
|
||||
try:
|
||||
await callback.answer(get_text("subscription_autorenew_updated"))
|
||||
except Exception:
|
||||
pass
|
||||
await my_subscription_command_handler(callback, i18n_data, settings, panel_service, subscription_service, session, bot)
|
||||
|
||||
|
||||
@router.callback_query(F.data == "autorenew:cancel")
|
||||
async def autorenew_cancel_from_webhook_button(
|
||||
callback: types.CallbackQuery,
|
||||
settings: Settings,
|
||||
i18n_data: dict,
|
||||
session: AsyncSession,
|
||||
subscription_service: SubscriptionService,
|
||||
panel_service: PanelApiService,
|
||||
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
|
||||
|
||||
# Disable auto-renew on the active subscription (non-tribute)
|
||||
from db.dal import subscription_dal
|
||||
sub = await subscription_dal.get_active_subscription_by_user_id(session, callback.from_user.id)
|
||||
if not sub:
|
||||
try:
|
||||
await callback.answer(get_text("subscription_not_active"), show_alert=True)
|
||||
except Exception:
|
||||
pass
|
||||
return
|
||||
if sub.provider == "tribute":
|
||||
try:
|
||||
await callback.answer(get_text("subscription_autorenew_not_supported_for_tribute"), show_alert=True)
|
||||
except Exception:
|
||||
pass
|
||||
return
|
||||
await subscription_dal.update_subscription(session, sub.subscription_id, {"auto_renew_enabled": False})
|
||||
await session.commit()
|
||||
try:
|
||||
await callback.answer(get_text("subscription_autorenew_updated"))
|
||||
except Exception:
|
||||
pass
|
||||
await my_subscription_command_handler(callback, i18n_data, settings, panel_service, subscription_service, session, bot)
|
||||
|
||||
|
||||
@router.message(Command("connect"))
|
||||
async def connect_command_handler(
|
||||
message: types.Message,
|
||||
i18n_data: dict,
|
||||
settings: Settings,
|
||||
panel_service: PanelApiService,
|
||||
subscription_service: SubscriptionService,
|
||||
session: AsyncSession,
|
||||
bot: Bot,
|
||||
):
|
||||
logging.info(f"User {message.from_user.id} used /connect command.")
|
||||
await my_subscription_command_handler(message, i18n_data, settings, panel_service, subscription_service, session, bot)
|
||||
|
||||
|
||||
@@ -0,0 +1,458 @@
|
||||
from aiogram import Router, F, types
|
||||
from typing import Optional, List
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from config.settings import Settings
|
||||
from bot.keyboards.inline.user_keyboards import (
|
||||
get_payment_methods_list_keyboard,
|
||||
get_payment_method_delete_confirm_keyboard,
|
||||
get_payment_method_details_keyboard,
|
||||
get_bind_url_keyboard,
|
||||
)
|
||||
from bot.services.yookassa_service import YooKassaService
|
||||
from bot.middlewares.i18n import JsonI18n
|
||||
from db.dal import user_billing_dal
|
||||
from db.models import Payment
|
||||
from sqlalchemy.future import select
|
||||
|
||||
router = Router(name="user_subscription_payment_methods_router")
|
||||
|
||||
|
||||
@router.callback_query(F.data == "pm:manage")
|
||||
async def payment_methods_manage(callback: types.CallbackQuery, settings: Settings, i18n_data: dict, session: AsyncSession):
|
||||
current_lang = i18n_data.get("current_language", settings.DEFAULT_LANGUAGE)
|
||||
i18n: Optional[JsonI18n] = i18n_data.get("i18n_instance")
|
||||
if not getattr(settings, 'YOOKASSA_AUTOPAYMENTS_ENABLED', False):
|
||||
try:
|
||||
_ = lambda key, **kwargs: i18n.gettext(current_lang, key, **kwargs) if i18n else key
|
||||
await callback.answer(_("error_service_unavailable"), show_alert=True)
|
||||
except Exception:
|
||||
pass
|
||||
return
|
||||
_ = lambda key, **kwargs: i18n.gettext(current_lang, key, **kwargs) if i18n else key
|
||||
|
||||
from db.dal.user_billing_dal import list_user_payment_methods
|
||||
get_text = _
|
||||
methods = await list_user_payment_methods(session, callback.from_user.id)
|
||||
cards: List[tuple] = []
|
||||
|
||||
def _is_yoomoney_network(network: Optional[str]) -> bool:
|
||||
s = (network or "").lower()
|
||||
return "yoomoney" in s or "yoo money" in s or "yoo-money" in s
|
||||
|
||||
def _extract_last4(text: str) -> Optional[str]:
|
||||
digits = "".join(ch for ch in text if ch.isdigit())
|
||||
return digits[-4:] if len(digits) >= 4 else None
|
||||
|
||||
def _format_pm_title(network: Optional[str], last4: Optional[str]) -> str:
|
||||
if _is_yoomoney_network(network):
|
||||
l4 = last4 or _extract_last4(network or "")
|
||||
if l4:
|
||||
return get_text("payment_method_wallet_title", last4=l4)
|
||||
return get_text("payment_method_wallet_title", last4="****")
|
||||
if last4:
|
||||
network_name = network or get_text("payment_network_card", default="Card")
|
||||
return get_text("payment_method_card_title", network=network_name, last4=last4)
|
||||
network_name = network or get_text("payment_network_generic", default="Payment method")
|
||||
return get_text("payment_method_generic_title", network=network_name)
|
||||
|
||||
for m in methods:
|
||||
title = _format_pm_title(m.card_network, m.card_last4)
|
||||
cards.append((str(m.method_id), title if not m.is_default else f"⭐ {title}"))
|
||||
|
||||
text = get_text("payment_methods_title")
|
||||
if not cards:
|
||||
text += "\n\n" + get_text("payment_method_none")
|
||||
|
||||
await callback.message.edit_text(text, reply_markup=get_payment_methods_list_keyboard(cards, 0, current_lang, i18n))
|
||||
try:
|
||||
await callback.answer()
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
|
||||
@router.callback_query(F.data == "pm:bind")
|
||||
async def payment_method_bind(callback: types.CallbackQuery, settings: Settings, i18n_data: dict, session: AsyncSession, yookassa_service: YooKassaService):
|
||||
current_lang = i18n_data.get("current_language", settings.DEFAULT_LANGUAGE)
|
||||
i18n: Optional[JsonI18n] = i18n_data.get("i18n_instance")
|
||||
if not getattr(settings, 'YOOKASSA_AUTOPAYMENTS_ENABLED', False):
|
||||
try:
|
||||
_ = lambda key, **kwargs: i18n.gettext(current_lang, key, **kwargs) if i18n else key
|
||||
await callback.answer(_("error_service_unavailable"), show_alert=True)
|
||||
except Exception:
|
||||
pass
|
||||
return
|
||||
_ = lambda key, **kwargs: i18n.gettext(current_lang, key, **kwargs) if i18n else key
|
||||
|
||||
metadata = {"user_id": str(callback.from_user.id), "bind_only": "1"}
|
||||
resp = await yookassa_service.create_payment(
|
||||
amount=1.00,
|
||||
currency="RUB",
|
||||
description="Bind card",
|
||||
metadata=metadata,
|
||||
receipt_email=settings.YOOKASSA_DEFAULT_RECEIPT_EMAIL,
|
||||
save_payment_method=True,
|
||||
capture=False,
|
||||
bind_only=True,
|
||||
)
|
||||
if not resp or not resp.get("confirmation_url"):
|
||||
await callback.answer(_("error_payment_gateway"), show_alert=True)
|
||||
return
|
||||
await callback.message.edit_text(_("payment_methods_title"), reply_markup=get_bind_url_keyboard(resp["confirmation_url"], current_lang, i18n))
|
||||
try:
|
||||
await callback.answer()
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
|
||||
@router.callback_query(F.data.startswith("pm:delete_confirm"))
|
||||
async def payment_method_delete_confirm(callback: types.CallbackQuery, settings: Settings, i18n_data: dict):
|
||||
current_lang = i18n_data.get("current_language", settings.DEFAULT_LANGUAGE)
|
||||
i18n: Optional[JsonI18n] = i18n_data.get("i18n_instance")
|
||||
if not getattr(settings, 'YOOKASSA_AUTOPAYMENTS_ENABLED', False):
|
||||
try:
|
||||
_ = lambda key, **kwargs: i18n.gettext(current_lang, key, **kwargs) if i18n else key
|
||||
await callback.answer(_("error_service_unavailable"), show_alert=True)
|
||||
except Exception:
|
||||
pass
|
||||
return
|
||||
_ = lambda key, **kwargs: i18n.gettext(current_lang, key, **kwargs) if i18n else key
|
||||
parts = callback.data.split(":", 2)
|
||||
pm_id = parts[2] if len(parts) >= 3 else ""
|
||||
await callback.message.edit_text(_("payment_method_delete_confirm"), reply_markup=get_payment_method_delete_confirm_keyboard(pm_id, current_lang, i18n))
|
||||
try:
|
||||
await callback.answer()
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
|
||||
@router.callback_query(F.data.startswith("pm:delete"))
|
||||
async def payment_method_delete(callback: types.CallbackQuery, settings: Settings, i18n_data: dict, session: AsyncSession):
|
||||
current_lang = i18n_data.get("current_language", settings.DEFAULT_LANGUAGE)
|
||||
i18n: Optional[JsonI18n] = i18n_data.get("i18n_instance")
|
||||
if not getattr(settings, 'YOOKASSA_AUTOPAYMENTS_ENABLED', False):
|
||||
try:
|
||||
_ = lambda key, **kwargs: i18n.gettext(current_lang, key, **kwargs) if i18n else key
|
||||
await callback.answer(_("error_service_unavailable"), show_alert=True)
|
||||
except Exception:
|
||||
pass
|
||||
return
|
||||
_ = lambda key, **kwargs: i18n.gettext(current_lang, key, **kwargs) if i18n else key
|
||||
parts = callback.data.split(":", 2)
|
||||
pm_id_raw = parts[2] if len(parts) >= 3 else ""
|
||||
deleted = False
|
||||
|
||||
try:
|
||||
from db.dal.user_billing_dal import (
|
||||
delete_user_payment_method,
|
||||
delete_user_payment_method_by_provider_id,
|
||||
list_user_payment_methods,
|
||||
)
|
||||
if pm_id_raw:
|
||||
if pm_id_raw.isdigit():
|
||||
deleted = await delete_user_payment_method(session, callback.from_user.id, int(pm_id_raw))
|
||||
else:
|
||||
deleted = await delete_user_payment_method_by_provider_id(session, callback.from_user.id, pm_id_raw)
|
||||
try:
|
||||
legacy_deleted = await user_billing_dal.delete_yk_payment_method(session, callback.from_user.id)
|
||||
deleted = deleted or legacy_deleted
|
||||
except Exception:
|
||||
pass
|
||||
await session.commit()
|
||||
|
||||
methods = await list_user_payment_methods(session, callback.from_user.id)
|
||||
text = _("payment_methods_title")
|
||||
cards = []
|
||||
for m in methods:
|
||||
def _is_yoomoney_network(network: Optional[str]) -> bool:
|
||||
s = (network or "").lower()
|
||||
return "yoomoney" in s or "yoo money" in s or "yoo-money" in s
|
||||
def _extract_last4(text: str) -> Optional[str]:
|
||||
digits = "".join(ch for ch in text if ch.isdigit())
|
||||
return digits[-4:] if len(digits) >= 4 else None
|
||||
def _format_pm_title(network: Optional[str], last4: Optional[str]) -> str:
|
||||
if _is_yoomoney_network(network):
|
||||
l4 = last4 or _extract_last4(network or "")
|
||||
if l4:
|
||||
return _("payment_method_wallet_title", last4=l4)
|
||||
return _("payment_method_wallet_title", last4="****")
|
||||
if last4:
|
||||
network_name = network or _("payment_network_card", default="Card")
|
||||
return _("payment_method_card_title", network=network_name, last4=last4)
|
||||
network_name = network or _("payment_network_generic", default="Payment method")
|
||||
return _("payment_method_generic_title", network=network_name)
|
||||
title = _format_pm_title(m.card_network, m.card_last4)
|
||||
cards.append((str(m.method_id), title if not m.is_default else f"⭐ {title}"))
|
||||
if not cards:
|
||||
text += "\n\n" + _("payment_method_none")
|
||||
msg = _("payment_method_deleted_success") if deleted else _("error_try_again")
|
||||
await callback.message.edit_text(f"{msg}\n\n{text}", reply_markup=get_payment_methods_list_keyboard(cards, 0, current_lang, i18n))
|
||||
try:
|
||||
await callback.answer()
|
||||
except Exception:
|
||||
pass
|
||||
return
|
||||
except Exception:
|
||||
await session.rollback()
|
||||
try:
|
||||
await callback.answer(_("error_try_again"), show_alert=True)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
|
||||
@router.callback_query(F.data.startswith("pm:view"))
|
||||
async def payment_method_view(callback: types.CallbackQuery, settings: Settings, i18n_data: dict, session: AsyncSession):
|
||||
current_lang = i18n_data.get("current_language", settings.DEFAULT_LANGUAGE)
|
||||
i18n: Optional[JsonI18n] = i18n_data.get("i18n_instance")
|
||||
if not getattr(settings, 'YOOKASSA_AUTOPAYMENTS_ENABLED', False):
|
||||
try:
|
||||
_ = lambda key, **kwargs: i18n.gettext(current_lang, key, **kwargs) if i18n else key
|
||||
await callback.answer(_("error_service_unavailable"), show_alert=True)
|
||||
except Exception:
|
||||
pass
|
||||
return
|
||||
_ = lambda key, **kwargs: i18n.gettext(current_lang, key, **kwargs) if i18n else key
|
||||
|
||||
billing = await user_billing_dal.get_user_billing(session, callback.from_user.id)
|
||||
if not billing or not billing.yookassa_payment_method_id:
|
||||
from db.dal.user_billing_dal import list_user_payment_methods
|
||||
methods = await list_user_payment_methods(session, callback.from_user.id)
|
||||
if not methods:
|
||||
await callback.answer(_("payment_method_none"), show_alert=True)
|
||||
return
|
||||
parts = callback.data.split(":", 2)
|
||||
pm_id = parts[2] if len(parts) >= 3 else str(methods[0].method_id)
|
||||
sel = next((m for m in methods if str(m.method_id) == pm_id or m.provider_payment_method_id == pm_id), methods[0])
|
||||
|
||||
def _is_yoomoney_network(network: Optional[str]) -> bool:
|
||||
s = (network or "").lower()
|
||||
return "yoomoney" in s or "yoo money" in s or "yoo-money" in s
|
||||
|
||||
def _extract_last4(text: str) -> Optional[str]:
|
||||
digits = "".join(ch for ch in text if ch.isdigit())
|
||||
return digits[-4:] if len(digits) >= 4 else None
|
||||
|
||||
def _format_pm_title(network: Optional[str], last4: Optional[str]) -> str:
|
||||
if _is_yoomoney_network(network):
|
||||
l4 = last4 or _extract_last4(network or "")
|
||||
if l4:
|
||||
return _("payment_method_wallet_title", last4=l4)
|
||||
return _("payment_method_wallet_title", last4="****")
|
||||
if last4:
|
||||
network_name = network or _("payment_network_card", default="Card")
|
||||
return _("payment_method_card_title", network=network_name, last4=last4)
|
||||
network_name = network or _("payment_network_generic", default="Payment method")
|
||||
return _("payment_method_generic_title", network=network_name)
|
||||
|
||||
title = _format_pm_title(sel.card_network, sel.card_last4)
|
||||
added_at = sel.created_at.strftime('%Y-%m-%d') if getattr(sel, 'created_at', None) else "—"
|
||||
last_tx = "—"
|
||||
try:
|
||||
stmt = (
|
||||
select(Payment)
|
||||
.where(
|
||||
Payment.user_id == callback.from_user.id,
|
||||
Payment.status == 'succeeded',
|
||||
Payment.provider == 'yookassa',
|
||||
)
|
||||
.order_by(Payment.created_at.desc())
|
||||
.limit(1)
|
||||
)
|
||||
result = await session.execute(stmt)
|
||||
lp = result.scalar_one_or_none()
|
||||
if lp and lp.created_at:
|
||||
last_tx = lp.created_at.strftime('%Y-%m-%d')
|
||||
except Exception:
|
||||
pass
|
||||
details = f"{title}\n{_('payment_method_added_at', date=added_at)}\n{_('payment_method_last_tx', date=last_tx)}"
|
||||
await callback.message.edit_text(details, reply_markup=get_payment_method_details_keyboard(str(sel.method_id), current_lang, i18n))
|
||||
try:
|
||||
await callback.answer()
|
||||
except Exception:
|
||||
pass
|
||||
return
|
||||
|
||||
added_at = billing.created_at.strftime('%Y-%m-%d') if getattr(billing, 'created_at', None) else "—"
|
||||
last_tx = "—"
|
||||
try:
|
||||
stmt = (
|
||||
select(Payment)
|
||||
.where(
|
||||
Payment.user_id == callback.from_user.id,
|
||||
Payment.status == 'succeeded',
|
||||
Payment.provider == 'yookassa',
|
||||
)
|
||||
.order_by(Payment.created_at.desc())
|
||||
.limit(1)
|
||||
)
|
||||
result = await session.execute(stmt)
|
||||
last_payment = result.scalar_one_or_none()
|
||||
if last_payment and last_payment.created_at:
|
||||
last_tx = last_payment.created_at.strftime('%Y-%m-%d')
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
def _is_yoomoney_network(network: Optional[str]) -> bool:
|
||||
s = (network or "").lower()
|
||||
return "yoomoney" in s or "yoo money" in s or "yoo-money" in s
|
||||
|
||||
def _extract_last4(text: str) -> Optional[str]:
|
||||
digits = "".join(ch for ch in text if ch.isdigit())
|
||||
return digits[-4:] if len(digits) >= 4 else None
|
||||
|
||||
def _format_pm_title(network: Optional[str], last4: Optional[str]) -> str:
|
||||
if _is_yoomoney_network(network):
|
||||
l4 = last4 or _extract_last4(network or "")
|
||||
if l4:
|
||||
return _("payment_method_wallet_title", last4=l4)
|
||||
return _("payment_method_wallet_title", last4="****")
|
||||
if last4:
|
||||
network_name = network or _("payment_network_card", default="Card")
|
||||
return _("payment_method_card_title", network=network_name, last4=last4)
|
||||
network_name = network or _("payment_network_generic", default="Payment method")
|
||||
return _("payment_method_generic_title", network=network_name)
|
||||
|
||||
title = _format_pm_title(billing.card_network, billing.card_last4)
|
||||
details = f"{title}\n{_('payment_method_added_at', date=added_at)}\n{_('payment_method_last_tx', date=last_tx)}"
|
||||
await callback.message.edit_text(details, reply_markup=get_payment_method_details_keyboard(billing.yookassa_payment_method_id, current_lang, i18n))
|
||||
try:
|
||||
await callback.answer()
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
|
||||
@router.callback_query(F.data.startswith("pm:history"))
|
||||
async def payment_method_history(callback: types.CallbackQuery, settings: Settings, i18n_data: dict, session: AsyncSession, yookassa_service: YooKassaService):
|
||||
current_lang = i18n_data.get("current_language", settings.DEFAULT_LANGUAGE)
|
||||
i18n: Optional[JsonI18n] = i18n_data.get("i18n_instance")
|
||||
if not getattr(settings, 'YOOKASSA_AUTOPAYMENTS_ENABLED', False):
|
||||
try:
|
||||
_ = lambda key, **kwargs: i18n.gettext(current_lang, key, **kwargs) if i18n else key
|
||||
await callback.answer(_("error_service_unavailable"), show_alert=True)
|
||||
except Exception:
|
||||
pass
|
||||
return
|
||||
_ = lambda key, **kwargs: i18n.gettext(current_lang, key, **kwargs) if i18n else key
|
||||
|
||||
from db.dal import payment_dal
|
||||
payments = await payment_dal.get_recent_payment_logs_with_user(session, limit=30, offset=0)
|
||||
user_payments = [p for p in payments if p.user_id == callback.from_user.id]
|
||||
|
||||
selected_pm_provider_id: Optional[str] = None
|
||||
pm_filter_requested: bool = False
|
||||
try:
|
||||
split_a, split_b, split_pm_id = callback.data.split(":", 2)
|
||||
if split_pm_id:
|
||||
pm_filter_requested = True
|
||||
if split_pm_id.isdigit():
|
||||
from db.dal.user_billing_dal import list_user_payment_methods
|
||||
methods = await list_user_payment_methods(session, callback.from_user.id)
|
||||
sel = next((m for m in methods if str(m.method_id) == split_pm_id), None)
|
||||
if sel and sel.provider_payment_method_id:
|
||||
selected_pm_provider_id = sel.provider_payment_method_id
|
||||
else:
|
||||
selected_pm_provider_id = split_pm_id
|
||||
except Exception:
|
||||
selected_pm_provider_id = None
|
||||
pm_filter_requested = False
|
||||
|
||||
if pm_filter_requested and not selected_pm_provider_id:
|
||||
user_payments = []
|
||||
|
||||
if selected_pm_provider_id:
|
||||
filtered: List[Payment] = []
|
||||
for p in user_payments:
|
||||
if p.provider != 'yookassa':
|
||||
continue
|
||||
if p.yookassa_payment_id and yookassa_service:
|
||||
try:
|
||||
info = await yookassa_service.get_payment_info(p.yookassa_payment_id)
|
||||
pm = (info or {}).get("payment_method") or {}
|
||||
if pm.get("id") == selected_pm_provider_id:
|
||||
filtered.append(p)
|
||||
continue
|
||||
except Exception:
|
||||
pass
|
||||
user_payments = filtered
|
||||
|
||||
if not user_payments:
|
||||
from bot.keyboards.inline.user_keyboards import get_back_to_payment_method_details_keyboard, get_payment_methods_manage_keyboard
|
||||
back_pm_id = ""
|
||||
try:
|
||||
split_a, split_b, back_pm_id = callback.data.split(":", 2)
|
||||
except Exception:
|
||||
back_pm_id = ""
|
||||
back_markup = (
|
||||
get_back_to_payment_method_details_keyboard(back_pm_id, current_lang, i18n)
|
||||
if back_pm_id
|
||||
else get_payment_methods_manage_keyboard(current_lang, i18n, has_card=True)
|
||||
)
|
||||
await callback.message.edit_text(_("payment_method_no_history"), reply_markup=back_markup)
|
||||
return
|
||||
|
||||
def _format_item(p: Payment) -> str:
|
||||
title = p.description or _("subscription_purchase_title", months=p.subscription_duration_months or 1)
|
||||
date_str = p.created_at.strftime('%Y-%m-%d') if p.created_at else "N/A"
|
||||
return f"{date_str} — {title} — {p.amount:.2f} {p.currency}"
|
||||
|
||||
lines = [_format_item(p) for p in user_payments]
|
||||
text = _("payment_method_tx_history_title") + "\n\n" + "\n".join(lines)
|
||||
try:
|
||||
split_a, split_b, split_pm_id_for_back = callback.data.split(":", 2)
|
||||
except Exception:
|
||||
split_pm_id_for_back = ""
|
||||
from bot.keyboards.inline.user_keyboards import get_back_to_payment_method_details_keyboard, get_payment_methods_manage_keyboard
|
||||
back_markup = (
|
||||
get_back_to_payment_method_details_keyboard(split_pm_id_for_back, current_lang, i18n)
|
||||
if split_pm_id_for_back
|
||||
else get_payment_methods_manage_keyboard(current_lang, i18n, has_card=True)
|
||||
)
|
||||
await callback.message.edit_text(text, reply_markup=back_markup)
|
||||
|
||||
|
||||
@router.callback_query(F.data.startswith("pm:list:"))
|
||||
async def payment_methods_list(callback: types.CallbackQuery, settings: Settings, i18n_data: dict, session: AsyncSession):
|
||||
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
|
||||
|
||||
from db.dal.user_billing_dal import list_user_payment_methods
|
||||
cards: List[tuple] = []
|
||||
methods = await list_user_payment_methods(session, callback.from_user.id)
|
||||
for m in methods:
|
||||
def _is_yoomoney_network(network: Optional[str]) -> bool:
|
||||
s = (network or "").lower()
|
||||
return "yoomoney" in s or "yoo money" in s or "yoo-money" in s
|
||||
def _extract_last4(text: str) -> Optional[str]:
|
||||
digits = "".join(ch for ch in text if ch.isdigit())
|
||||
return digits[-4:] if len(digits) >= 4 else None
|
||||
def _format_pm_title(network: Optional[str], last4: Optional[str]) -> str:
|
||||
if _is_yoomoney_network(network):
|
||||
l4 = last4 or _extract_last4(network or "")
|
||||
if l4:
|
||||
return get_text("payment_method_wallet_title", last4=l4)
|
||||
return get_text("payment_method_wallet_title", last4="****")
|
||||
if last4:
|
||||
network_name = network or get_text("payment_network_card", default="Card")
|
||||
return get_text("payment_method_card_title", network=network_name, last4=last4)
|
||||
network_name = network or get_text("payment_network_generic", default="Payment method")
|
||||
return get_text("payment_method_generic_title", network=network_name)
|
||||
title = _format_pm_title(m.card_network, m.card_last4)
|
||||
cards.append((str(m.method_id), title if not m.is_default else f"⭐ {title}"))
|
||||
|
||||
try:
|
||||
_, _, page_str = callback.data.split(":", 2)
|
||||
page = int(page_str)
|
||||
except Exception:
|
||||
page = 0
|
||||
|
||||
text = get_text("payment_methods_title")
|
||||
if not cards:
|
||||
text += "\n\n" + get_text("payment_method_none")
|
||||
await callback.message.edit_text(text, reply_markup=get_payment_methods_list_keyboard(cards, page, current_lang, i18n))
|
||||
try:
|
||||
await callback.answer()
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
|
||||
@@ -0,0 +1,438 @@
|
||||
import logging
|
||||
from aiogram import Router, F, types
|
||||
from typing import Optional
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from config.settings import Settings
|
||||
from bot.keyboards.inline.user_keyboards import get_payment_method_keyboard, get_payment_url_keyboard
|
||||
from bot.services.yookassa_service import YooKassaService
|
||||
from bot.services.crypto_pay_service import CryptoPayService
|
||||
from bot.services.stars_service import StarsService
|
||||
from bot.middlewares.i18n import JsonI18n
|
||||
from db.dal import payment_dal, user_billing_dal
|
||||
|
||||
router = Router(name="user_subscription_payments_router")
|
||||
|
||||
|
||||
@router.callback_query(F.data.startswith("subscribe_period:"))
|
||||
async def select_subscription_period_callback_handler(callback: types.CallbackQuery, settings: Settings, i18n_data: dict, session: AsyncSession):
|
||||
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:
|
||||
try:
|
||||
await callback.answer(get_text("error_occurred_try_again"), show_alert=True)
|
||||
except Exception:
|
||||
pass
|
||||
return
|
||||
|
||||
try:
|
||||
months = int(callback.data.split(":")[-1])
|
||||
except (ValueError, IndexError):
|
||||
logging.error(f"Invalid subscription period in callback_data: {callback.data}")
|
||||
try:
|
||||
await callback.answer(get_text("error_try_again"), show_alert=True)
|
||||
except Exception:
|
||||
pass
|
||||
return
|
||||
|
||||
price_rub = settings.subscription_options.get(months)
|
||||
if price_rub is None:
|
||||
logging.error(
|
||||
f"Price not found for {months} months subscription period in settings.subscription_options."
|
||||
)
|
||||
try:
|
||||
await callback.answer(get_text("error_try_again"), show_alert=True)
|
||||
except Exception:
|
||||
pass
|
||||
return
|
||||
|
||||
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,
|
||||
i18n,
|
||||
settings,
|
||||
)
|
||||
|
||||
try:
|
||||
await callback.message.edit_text(text_content, reply_markup=reply_markup)
|
||||
except Exception as e_edit:
|
||||
logging.warning(
|
||||
f"Edit message for payment method selection failed: {e_edit}. Sending new one."
|
||||
)
|
||||
await callback.message.answer(text_content, reply_markup=reply_markup)
|
||||
try:
|
||||
await callback.answer()
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
|
||||
@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)
|
||||
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:
|
||||
try:
|
||||
await callback.answer(get_text("error_occurred_try_again"), show_alert=True)
|
||||
except Exception:
|
||||
pass
|
||||
return
|
||||
|
||||
if not yookassa_service or not yookassa_service.configured:
|
||||
logging.error("YooKassa service is not configured or unavailable.")
|
||||
target_msg_edit = callback.message
|
||||
await target_msg_edit.edit_text(get_text("payment_service_unavailable"))
|
||||
try:
|
||||
await callback.answer(get_text("payment_service_unavailable_alert"), show_alert=True)
|
||||
except Exception:
|
||||
pass
|
||||
return
|
||||
|
||||
try:
|
||||
_, data_payload = callback.data.split(":", 1)
|
||||
months_str, price_str = data_payload.split(":")
|
||||
months = int(months_str)
|
||||
price_rub = float(price_str)
|
||||
except (ValueError, IndexError):
|
||||
logging.error(f"Invalid pay_yk data in callback: {callback.data}")
|
||||
try:
|
||||
await callback.answer(get_text("error_try_again"), show_alert=True)
|
||||
except Exception:
|
||||
pass
|
||||
return
|
||||
|
||||
user_id = callback.from_user.id
|
||||
payment_description = get_text("payment_description_subscription", months=months)
|
||||
currency_code_for_yk = "RUB"
|
||||
|
||||
payment_record_data = {
|
||||
"user_id": user_id,
|
||||
"amount": price_rub,
|
||||
"currency": currency_code_for_yk,
|
||||
"status": "pending_yookassa",
|
||||
"description": payment_description,
|
||||
"subscription_duration_months": months,
|
||||
}
|
||||
|
||||
db_payment_record = None
|
||||
try:
|
||||
db_payment_record = await payment_dal.create_payment_record(session, payment_record_data)
|
||||
await session.commit()
|
||||
logging.info(
|
||||
f"Payment record {db_payment_record.payment_id} created for user {user_id} with status 'pending_yookassa'."
|
||||
)
|
||||
except Exception as e_db_payment:
|
||||
await session.rollback()
|
||||
logging.error(
|
||||
f"Failed to create payment record in DB for user {user_id}: {e_db_payment}",
|
||||
exc_info=True,
|
||||
)
|
||||
await callback.message.edit_text(get_text("error_creating_payment_record"))
|
||||
try:
|
||||
await callback.answer(get_text("error_try_again"), show_alert=True)
|
||||
except Exception:
|
||||
pass
|
||||
return
|
||||
|
||||
if not db_payment_record:
|
||||
await callback.message.edit_text(get_text("error_creating_payment_record"))
|
||||
try:
|
||||
await callback.answer(get_text("error_try_again"), show_alert=True)
|
||||
except Exception:
|
||||
pass
|
||||
return
|
||||
|
||||
yookassa_metadata = {
|
||||
"user_id": str(user_id),
|
||||
"subscription_months": str(months),
|
||||
"payment_db_id": str(db_payment_record.payment_id),
|
||||
}
|
||||
receipt_email_for_yk = settings.YOOKASSA_DEFAULT_RECEIPT_EMAIL
|
||||
|
||||
payment_response_yk = await yookassa_service.create_payment(
|
||||
amount=price_rub,
|
||||
currency=currency_code_for_yk,
|
||||
description=payment_description,
|
||||
metadata=yookassa_metadata,
|
||||
receipt_email=receipt_email_for_yk,
|
||||
# Save method only when autopayments are enabled
|
||||
save_payment_method=bool(getattr(settings, 'YOOKASSA_AUTOPAYMENTS_ENABLED', False)),
|
||||
)
|
||||
|
||||
if payment_response_yk and payment_response_yk.get("confirmation_url"):
|
||||
pm = payment_response_yk.get("payment_method")
|
||||
try:
|
||||
if pm and pm.get("id"):
|
||||
pm_type = pm.get("type")
|
||||
title = pm.get("title")
|
||||
card = pm.get("card") or {}
|
||||
account_number = pm.get("account_number") or pm.get("account")
|
||||
if isinstance(card, dict) and (pm_type or "").lower() in {"bank_card", "bank-card", "card"}:
|
||||
display_network = card.get("card_type") or title or "Card"
|
||||
display_last4 = card.get("last4")
|
||||
elif (pm_type or "").lower() in {"yoo_money", "yoomoney", "yoo-money", "wallet"}:
|
||||
display_network = "YooMoney"
|
||||
display_last4 = (
|
||||
account_number[-4:]
|
||||
if isinstance(account_number, str) and len(account_number) >= 4
|
||||
else None
|
||||
)
|
||||
else:
|
||||
display_network = title or (pm_type.upper() if pm_type else "Payment method")
|
||||
display_last4 = None
|
||||
await user_billing_dal.upsert_yk_payment_method(
|
||||
session,
|
||||
user_id=user_id,
|
||||
payment_method_id=pm["id"],
|
||||
card_last4=display_last4,
|
||||
card_network=display_network,
|
||||
)
|
||||
try:
|
||||
await user_billing_dal.upsert_user_payment_method(
|
||||
session,
|
||||
user_id=user_id,
|
||||
provider_payment_method_id=pm["id"],
|
||||
provider="yookassa",
|
||||
card_last4=display_last4,
|
||||
card_network=display_network,
|
||||
set_default=True,
|
||||
)
|
||||
except Exception:
|
||||
pass
|
||||
await session.commit()
|
||||
except Exception:
|
||||
await session.rollback()
|
||||
logging.exception("Failed to save YooKassa payment method preliminarily")
|
||||
try:
|
||||
await payment_dal.update_payment_status_by_db_id(
|
||||
session,
|
||||
payment_db_id=db_payment_record.payment_id,
|
||||
new_status=payment_response_yk.get("status", "pending"),
|
||||
yk_payment_id=payment_response_yk.get("id"),
|
||||
)
|
||||
await session.commit()
|
||||
except Exception as e_db_update_ykid:
|
||||
await session.rollback()
|
||||
logging.error(
|
||||
f"Failed to update payment record {db_payment_record.payment_id} with YK ID: {e_db_update_ykid}",
|
||||
exc_info=True,
|
||||
)
|
||||
await callback.message.edit_text(get_text("error_payment_gateway_link_failed"))
|
||||
try:
|
||||
await callback.answer(get_text("error_try_again"), show_alert=True)
|
||||
except Exception:
|
||||
pass
|
||||
return
|
||||
|
||||
await callback.message.edit_text(
|
||||
get_text(key="payment_link_message", months=months),
|
||||
reply_markup=get_payment_url_keyboard(payment_response_yk["confirmation_url"], current_lang, i18n),
|
||||
disable_web_page_preview=False,
|
||||
)
|
||||
else:
|
||||
try:
|
||||
await payment_dal.update_payment_status_by_db_id(session, db_payment_record.payment_id, "failed_creation")
|
||||
await session.commit()
|
||||
except Exception as e_db_fail_create:
|
||||
await session.rollback()
|
||||
logging.error(
|
||||
f"Additionally failed to update payment record to 'failed_creation': {e_db_fail_create}",
|
||||
exc_info=True,
|
||||
)
|
||||
logging.error(
|
||||
f"Failed to create payment in YooKassa for user {user_id}, payment_db_id {db_payment_record.payment_id}. Response: {payment_response_yk}"
|
||||
)
|
||||
await callback.message.edit_text(get_text("error_payment_gateway"))
|
||||
|
||||
try:
|
||||
await callback.answer()
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
|
||||
@router.callback_query(F.data.startswith("pay_crypto:"))
|
||||
async def pay_crypto_callback_handler(
|
||||
callback: types.CallbackQuery,
|
||||
settings: Settings,
|
||||
i18n_data: dict,
|
||||
session: AsyncSession,
|
||||
cryptopay_service: CryptoPayService,
|
||||
):
|
||||
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:
|
||||
try:
|
||||
await callback.answer(get_text("error_occurred_try_again"), show_alert=True)
|
||||
except Exception:
|
||||
pass
|
||||
return
|
||||
|
||||
if not cryptopay_service or not getattr(cryptopay_service, "configured", False):
|
||||
try:
|
||||
await callback.answer(get_text("payment_service_unavailable_alert"), show_alert=True)
|
||||
except Exception:
|
||||
pass
|
||||
return
|
||||
|
||||
try:
|
||||
_, data_payload = callback.data.split(":", 1)
|
||||
months_str, price_str = data_payload.split(":")
|
||||
months = int(months_str)
|
||||
price_amount = float(price_str)
|
||||
except (ValueError, IndexError):
|
||||
try:
|
||||
await callback.answer(get_text("error_try_again"), show_alert=True)
|
||||
except Exception:
|
||||
pass
|
||||
return
|
||||
|
||||
user_id = callback.from_user.id
|
||||
payment_description = get_text("payment_description_subscription", months=months)
|
||||
|
||||
invoice_url = await cryptopay_service.create_invoice(
|
||||
session=session,
|
||||
user_id=user_id,
|
||||
months=months,
|
||||
amount=price_amount,
|
||||
description=payment_description,
|
||||
)
|
||||
|
||||
if invoice_url:
|
||||
try:
|
||||
await callback.message.edit_text(
|
||||
get_text(key="payment_link_message", months=months),
|
||||
reply_markup=get_payment_url_keyboard(invoice_url, current_lang, i18n),
|
||||
disable_web_page_preview=False,
|
||||
)
|
||||
except Exception:
|
||||
try:
|
||||
await callback.message.answer(
|
||||
get_text(key="payment_link_message", months=months),
|
||||
reply_markup=get_payment_url_keyboard(invoice_url, current_lang, i18n),
|
||||
disable_web_page_preview=False,
|
||||
)
|
||||
except Exception:
|
||||
pass
|
||||
try:
|
||||
await callback.answer()
|
||||
except Exception:
|
||||
pass
|
||||
return
|
||||
|
||||
try:
|
||||
await callback.answer(get_text("error_payment_gateway"), show_alert=True)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
|
||||
@router.callback_query(F.data.startswith("pay_stars:"))
|
||||
async def pay_stars_callback_handler(
|
||||
callback: types.CallbackQuery,
|
||||
settings: Settings,
|
||||
i18n_data: dict,
|
||||
session: AsyncSession,
|
||||
stars_service: StarsService,
|
||||
):
|
||||
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:
|
||||
try:
|
||||
await callback.answer(get_text("error_occurred_try_again"), show_alert=True)
|
||||
except Exception:
|
||||
pass
|
||||
return
|
||||
|
||||
if not settings.STARS_ENABLED:
|
||||
try:
|
||||
await callback.answer(get_text("payment_service_unavailable_alert"), show_alert=True)
|
||||
except Exception:
|
||||
pass
|
||||
return
|
||||
|
||||
try:
|
||||
_, data_payload = callback.data.split(":", 1)
|
||||
months_str, stars_price_str = data_payload.split(":")
|
||||
months = int(months_str)
|
||||
stars_price = int(stars_price_str)
|
||||
except (ValueError, IndexError):
|
||||
try:
|
||||
await callback.answer(get_text("error_try_again"), show_alert=True)
|
||||
except Exception:
|
||||
pass
|
||||
return
|
||||
|
||||
user_id = callback.from_user.id
|
||||
payment_description = get_text("payment_description_subscription", months=months)
|
||||
|
||||
payment_db_id = await stars_service.create_invoice(
|
||||
session=session,
|
||||
user_id=user_id,
|
||||
months=months,
|
||||
stars_price=stars_price,
|
||||
description=payment_description,
|
||||
)
|
||||
|
||||
if payment_db_id:
|
||||
try:
|
||||
await callback.answer()
|
||||
except Exception:
|
||||
pass
|
||||
return
|
||||
|
||||
try:
|
||||
await callback.answer(get_text("error_payment_gateway"), show_alert=True)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
|
||||
@router.pre_checkout_query()
|
||||
async def handle_pre_checkout_query(query: types.PreCheckoutQuery):
|
||||
try:
|
||||
await query.answer(ok=True)
|
||||
except Exception:
|
||||
# Nothing else to do here; Telegram will show an error if not answered
|
||||
pass
|
||||
|
||||
|
||||
@router.message(F.successful_payment)
|
||||
async def handle_successful_stars_payment(
|
||||
message: types.Message,
|
||||
settings: Settings,
|
||||
i18n_data: dict,
|
||||
session: AsyncSession,
|
||||
stars_service: StarsService,
|
||||
):
|
||||
payload = (message.successful_payment.invoice_payload
|
||||
if message and message.successful_payment else "")
|
||||
try:
|
||||
payment_db_id_str, months_str = (payload or "").split(":", 1)
|
||||
payment_db_id = int(payment_db_id_str)
|
||||
months = int(months_str)
|
||||
except Exception:
|
||||
return
|
||||
|
||||
stars_amount = int(message.successful_payment.total_amount) if message.successful_payment else 0
|
||||
await stars_service.process_successful_payment(
|
||||
session=session,
|
||||
message=message,
|
||||
payment_db_id=payment_db_id,
|
||||
months=months,
|
||||
stars_amount=stars_amount,
|
||||
i18n_data=i18n_data,
|
||||
)
|
||||
|
||||
@@ -113,6 +113,14 @@ async def request_trial_confirmation_handler(
|
||||
# Send notification to admin about new trial
|
||||
notification_service = NotificationService(callback.bot, settings, i18n)
|
||||
await notification_service.notify_trial_activation(user_id, end_date_obj)
|
||||
# Mark ad attribution trial if exists
|
||||
try:
|
||||
from db.dal import ad_dal as _ad_dal
|
||||
await _ad_dal.mark_trial_activated(session, user_id)
|
||||
await session.commit()
|
||||
except Exception as e_mark:
|
||||
await session.rollback()
|
||||
logging.error(f"Failed to mark trial for ad attribution for user {user_id}: {e_mark}")
|
||||
else:
|
||||
message_key_from_service = (
|
||||
activation_result.get("message_key", "trial_activation_failed")
|
||||
@@ -298,6 +306,13 @@ async def confirm_activate_trial_handler(
|
||||
if activation_result and activation_result.get("activated") and end_date_obj:
|
||||
notification_service = NotificationService(callback.bot, settings, i18n)
|
||||
await notification_service.notify_trial_activation(user_id, end_date_obj)
|
||||
try:
|
||||
from db.dal import ad_dal as _ad_dal
|
||||
await _ad_dal.mark_trial_activated(session, user_id)
|
||||
await session.commit()
|
||||
except Exception as e_mark:
|
||||
await session.rollback()
|
||||
logging.error(f"Failed to mark trial for ad attribution for user {user_id}: {e_mark}")
|
||||
|
||||
|
||||
@router.callback_query(F.data == "main_action:cancel_trial")
|
||||
|
||||
@@ -25,6 +25,10 @@ def get_admin_panel_keyboard(i18n_instance, lang: str,
|
||||
builder.button(text=_(key="admin_promo_marketing_section"),
|
||||
callback_data="admin_section:promo_marketing")
|
||||
|
||||
# Реклама
|
||||
builder.button(text=_(key="admin_ads_section", default="📈 Реклама"),
|
||||
callback_data="admin_action:ads")
|
||||
|
||||
# Системные функции
|
||||
builder.button(text=_(key="admin_system_functions_section"),
|
||||
callback_data="admin_section:system_functions")
|
||||
@@ -116,6 +120,79 @@ def get_system_functions_keyboard(i18n_instance, lang: str) -> InlineKeyboardMar
|
||||
return builder.as_markup()
|
||||
|
||||
|
||||
def get_ads_menu_keyboard(i18n_instance, lang: str) -> InlineKeyboardMarkup:
|
||||
_ = lambda key, **kwargs: i18n_instance.gettext(lang, key, **kwargs)
|
||||
builder = InlineKeyboardBuilder()
|
||||
builder.button(text=_(key="admin_ads_create_button", default="➕ Создать кампанию"),
|
||||
callback_data="admin_action:ads_create")
|
||||
builder.button(text=_(key="back_to_admin_panel_button"),
|
||||
callback_data="admin_action:main")
|
||||
builder.adjust(1, 1)
|
||||
return builder.as_markup()
|
||||
|
||||
|
||||
def get_ads_list_keyboard(
|
||||
i18n_instance,
|
||||
lang: str,
|
||||
campaigns: list,
|
||||
current_page: int,
|
||||
total_pages: int,
|
||||
) -> InlineKeyboardMarkup:
|
||||
_ = lambda key, **kwargs: i18n_instance.gettext(lang, key, **kwargs)
|
||||
builder = InlineKeyboardBuilder()
|
||||
|
||||
for c in campaigns:
|
||||
title = f"{c.source}"
|
||||
builder.button(
|
||||
text=title,
|
||||
callback_data=f"admin_ads:card:{c.ad_campaign_id}:{current_page}",
|
||||
)
|
||||
|
||||
# Pagination row (only when needed)
|
||||
if total_pages > 1:
|
||||
row = []
|
||||
if current_page > 0:
|
||||
row.append(
|
||||
InlineKeyboardButton(
|
||||
text="⬅️ " + _("prev_page_button", default="Prev"),
|
||||
callback_data=f"admin_ads:page:{current_page - 1}",
|
||||
)
|
||||
)
|
||||
row.append(
|
||||
InlineKeyboardButton(
|
||||
text=f"{current_page + 1}/{total_pages}",
|
||||
callback_data="ads_page_display",
|
||||
)
|
||||
)
|
||||
if current_page < total_pages - 1:
|
||||
row.append(
|
||||
InlineKeyboardButton(
|
||||
text=_("next_page_button", default="Next") + " ➡️",
|
||||
callback_data=f"admin_ads:page:{current_page + 1}",
|
||||
)
|
||||
)
|
||||
if row:
|
||||
builder.row(*row)
|
||||
|
||||
builder.button(text=_(key="admin_ads_create_button", default="➕ Создать кампанию"),
|
||||
callback_data="admin_action:ads_create")
|
||||
builder.button(text=_(key="back_to_admin_panel_button"),
|
||||
callback_data="admin_action:main")
|
||||
builder.adjust(1)
|
||||
return builder.as_markup()
|
||||
|
||||
|
||||
def get_ad_card_keyboard(i18n_instance, lang: str, campaign_id: int, back_page: int) -> InlineKeyboardMarkup:
|
||||
_ = lambda key, **kwargs: i18n_instance.gettext(lang, key, **kwargs)
|
||||
builder = InlineKeyboardBuilder()
|
||||
builder.button(text=_(key="back_to_ads_list_button", default="⬅️ К списку"),
|
||||
callback_data=f"admin_ads:page:{back_page}")
|
||||
builder.button(text=_(key="back_to_admin_panel_button"),
|
||||
callback_data="admin_action:main")
|
||||
builder.adjust(1)
|
||||
return builder.as_markup()
|
||||
|
||||
|
||||
def get_logs_menu_keyboard(i18n_instance, lang: str) -> InlineKeyboardMarkup:
|
||||
_ = lambda key, **kwargs: i18n_instance.gettext(lang, key, **kwargs)
|
||||
builder = InlineKeyboardBuilder()
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
from aiogram.utils.keyboard import InlineKeyboardBuilder, InlineKeyboardButton
|
||||
from aiogram.types import InlineKeyboardMarkup, WebAppInfo
|
||||
from typing import Dict, Optional, List
|
||||
from typing import Dict, Optional, List, Tuple
|
||||
|
||||
from config.settings import Settings
|
||||
|
||||
@@ -21,20 +21,12 @@ def get_main_menu_inline_keyboard(
|
||||
builder.row(
|
||||
InlineKeyboardButton(text=_(key="menu_subscribe_inline"),
|
||||
callback_data="main_action:subscribe"))
|
||||
if settings.SUBSCRIPTION_MINI_APP_URL:
|
||||
builder.row(
|
||||
InlineKeyboardButton(
|
||||
text=_(key="menu_my_subscription_inline"),
|
||||
web_app=WebAppInfo(url=settings.SUBSCRIPTION_MINI_APP_URL),
|
||||
)
|
||||
)
|
||||
else:
|
||||
builder.row(
|
||||
InlineKeyboardButton(
|
||||
text=_(key="menu_my_subscription_inline"),
|
||||
callback_data="main_action:my_subscription",
|
||||
)
|
||||
builder.row(
|
||||
InlineKeyboardButton(
|
||||
text=_(key="menu_my_subscription_inline"),
|
||||
callback_data="main_action:my_subscription",
|
||||
)
|
||||
)
|
||||
|
||||
referral_button = InlineKeyboardButton(
|
||||
text=_(key="menu_referral_inline"),
|
||||
@@ -229,3 +221,124 @@ def get_connect_and_main_keyboard(
|
||||
)
|
||||
|
||||
return builder.as_markup()
|
||||
|
||||
|
||||
def get_payment_methods_manage_keyboard(lang: str, i18n_instance, has_card: bool) -> InlineKeyboardMarkup:
|
||||
"""Deprecated in favor of get_payment_methods_list_keyboard. Kept for backward compatibility."""
|
||||
_ = lambda key, **kwargs: i18n_instance.gettext(lang, key, **kwargs)
|
||||
builder = InlineKeyboardBuilder()
|
||||
builder.row(
|
||||
InlineKeyboardButton(text=_(key="payment_method_bind_button"), callback_data="pm:bind")
|
||||
)
|
||||
builder.row(
|
||||
InlineKeyboardButton(text=_(key="back_to_main_menu_button"), callback_data="main_action:back_to_main")
|
||||
)
|
||||
return builder.as_markup()
|
||||
|
||||
|
||||
def get_payment_methods_list_keyboard(
|
||||
cards: List[Tuple[str, str]],
|
||||
page: int,
|
||||
lang: str,
|
||||
i18n_instance,
|
||||
) -> InlineKeyboardMarkup:
|
||||
"""
|
||||
Build a paginated list of saved payment methods.
|
||||
cards: list of tuples (payment_method_id, display_title)
|
||||
page: 0-based page index
|
||||
"""
|
||||
_ = lambda key, **kwargs: i18n_instance.gettext(lang, key, **kwargs)
|
||||
builder = InlineKeyboardBuilder()
|
||||
per_page = 5
|
||||
total = len(cards)
|
||||
start = page * per_page
|
||||
end = start + per_page
|
||||
for pm_id, title in cards[start:end]:
|
||||
builder.row(
|
||||
InlineKeyboardButton(text=title, callback_data=f"pm:view:{pm_id}")
|
||||
)
|
||||
|
||||
# Pagination controls if needed
|
||||
nav_buttons: List[InlineKeyboardButton] = []
|
||||
if start > 0:
|
||||
nav_buttons.append(InlineKeyboardButton(text="⬅️", callback_data=f"pm:list:{page-1}"))
|
||||
if end < total:
|
||||
nav_buttons.append(InlineKeyboardButton(text="➡️", callback_data=f"pm:list:{page+1}"))
|
||||
if nav_buttons:
|
||||
builder.row(*nav_buttons)
|
||||
|
||||
# Bind new card and back
|
||||
builder.row(InlineKeyboardButton(text=_(key="payment_method_bind_button"), callback_data="pm:bind"))
|
||||
builder.row(InlineKeyboardButton(text=_(key="back_to_main_menu_button"), callback_data="main_action:back_to_main"))
|
||||
return builder.as_markup()
|
||||
|
||||
|
||||
def get_payment_method_delete_confirm_keyboard(pm_id: str, lang: str, i18n_instance) -> InlineKeyboardMarkup:
|
||||
_ = lambda key, **kwargs: i18n_instance.gettext(lang, key, **kwargs)
|
||||
builder = InlineKeyboardBuilder()
|
||||
builder.row(
|
||||
InlineKeyboardButton(text=_(key="yes_button"), callback_data=f"pm:delete:{pm_id}"),
|
||||
InlineKeyboardButton(text=_(key="cancel_button"), callback_data=f"pm:view:{pm_id}"),
|
||||
)
|
||||
return builder.as_markup()
|
||||
|
||||
|
||||
def get_payment_method_details_keyboard(pm_id: str, lang: str, i18n_instance) -> InlineKeyboardMarkup:
|
||||
_ = lambda key, **kwargs: i18n_instance.gettext(lang, key, **kwargs)
|
||||
builder = InlineKeyboardBuilder()
|
||||
builder.row(
|
||||
InlineKeyboardButton(text=_(key="payment_method_tx_history_title"), callback_data=f"pm:history:{pm_id}")
|
||||
)
|
||||
builder.row(
|
||||
InlineKeyboardButton(text=_(key="payment_method_delete_button"), callback_data=f"pm:delete_confirm:{pm_id}")
|
||||
)
|
||||
builder.row(
|
||||
InlineKeyboardButton(text=_(key="back_to_main_menu_button"), callback_data="pm:list:0")
|
||||
)
|
||||
return builder.as_markup()
|
||||
|
||||
|
||||
def get_bind_url_keyboard(bind_url: str, lang: str, i18n_instance) -> InlineKeyboardMarkup:
|
||||
_ = lambda key, **kwargs: i18n_instance.gettext(lang, key, **kwargs)
|
||||
builder = InlineKeyboardBuilder()
|
||||
builder.button(text=_(key="payment_method_bind_button"), url=bind_url)
|
||||
builder.button(text=_(key="back_to_main_menu_button"), callback_data="pm:manage")
|
||||
builder.adjust(1)
|
||||
return builder.as_markup()
|
||||
|
||||
|
||||
def get_back_to_payment_methods_keyboard(lang: str, i18n_instance) -> InlineKeyboardMarkup:
|
||||
_ = lambda key, **kwargs: i18n_instance.gettext(lang, key, **kwargs)
|
||||
builder = InlineKeyboardBuilder()
|
||||
builder.row(InlineKeyboardButton(text=_(key="back_to_main_menu_button"), callback_data="pm:list:0"))
|
||||
return builder.as_markup()
|
||||
|
||||
|
||||
def get_back_to_payment_method_details_keyboard(pm_id: str, lang: str, i18n_instance) -> InlineKeyboardMarkup:
|
||||
_ = lambda key, **kwargs: i18n_instance.gettext(lang, key, **kwargs)
|
||||
builder = InlineKeyboardBuilder()
|
||||
# Back one step: return to specific payment method details
|
||||
builder.row(InlineKeyboardButton(text=_(key="back_to_main_menu_button"), callback_data=f"pm:view:{pm_id}"))
|
||||
return builder.as_markup()
|
||||
|
||||
|
||||
def get_autorenew_cancel_keyboard(lang: str, i18n_instance) -> InlineKeyboardMarkup:
|
||||
_ = lambda key, **kwargs: i18n_instance.gettext(lang, key, **kwargs)
|
||||
builder = InlineKeyboardBuilder()
|
||||
builder.row(
|
||||
InlineKeyboardButton(text=_(key="autorenew_disable_button"), callback_data="autorenew:cancel")
|
||||
)
|
||||
builder.row(
|
||||
InlineKeyboardButton(text=_(key="menu_my_subscription_inline"), callback_data="main_action:my_subscription")
|
||||
)
|
||||
return builder.as_markup()
|
||||
|
||||
|
||||
def get_autorenew_confirm_keyboard(enable: bool, sub_id: int, lang: str, i18n_instance) -> InlineKeyboardMarkup:
|
||||
_ = lambda key, **kwargs: i18n_instance.gettext(lang, key, **kwargs)
|
||||
builder = InlineKeyboardBuilder()
|
||||
builder.row(
|
||||
InlineKeyboardButton(text=_(key="yes_button"), callback_data=f"autorenew:confirm:{sub_id}:{1 if enable else 0}"),
|
||||
InlineKeyboardButton(text=_(key="no_button"), callback_data="main_action:my_subscription"),
|
||||
)
|
||||
return builder.as_markup()
|
||||
|
||||
@@ -293,6 +293,8 @@ async def run_bot(settings_param: Settings):
|
||||
|
||||
main_tasks.append(asyncio.create_task(web_server_task(), name="AIOHTTPServerTask"))
|
||||
|
||||
# Recurring billing moved to panel webhook (24h before expiry). No periodic task needed here.
|
||||
|
||||
logging.info("Starting bot in Webhook mode with AIOHTTP server...")
|
||||
logging.info(f"Starting bot with main tasks: {[task.get_name() for task in main_tasks]}")
|
||||
|
||||
|
||||
+18
-1
@@ -45,10 +45,27 @@ class JsonI18n:
|
||||
exc_info=True)
|
||||
|
||||
def gettext(self, lang_code: Optional[str], key: str, **kwargs) -> str:
|
||||
effective_lang_code = lang_code if lang_code and lang_code in self.locales_data else self.default_lang
|
||||
# Determine effective language with robust fallback
|
||||
if lang_code and lang_code in self.locales_data:
|
||||
effective_lang_code = lang_code
|
||||
elif self.default_lang in self.locales_data:
|
||||
effective_lang_code = self.default_lang
|
||||
elif 'en' in self.locales_data:
|
||||
effective_lang_code = 'en'
|
||||
else:
|
||||
effective_lang_code = lang_code or self.default_lang
|
||||
|
||||
lang_data = self.locales_data.get(effective_lang_code)
|
||||
if lang_data is None:
|
||||
# Try explicit fallback to English if available
|
||||
fallback_data = self.locales_data.get('en')
|
||||
if fallback_data is not None:
|
||||
text = fallback_data.get(key)
|
||||
if text is not None:
|
||||
try:
|
||||
return text.format(**kwargs) if kwargs else text
|
||||
except Exception:
|
||||
return text
|
||||
logging.warning(
|
||||
f"No language data for '{effective_lang_code}' (default '{self.default_lang}' also missing). Key '{key}' will be returned as is."
|
||||
)
|
||||
|
||||
@@ -67,18 +67,28 @@ class CryptoPayService:
|
||||
logging.error("CryptoPayService not configured")
|
||||
return None
|
||||
|
||||
payment_record = await payment_dal.create_payment_record(
|
||||
session,
|
||||
{
|
||||
"user_id": user_id,
|
||||
"amount": float(amount),
|
||||
"currency": self.settings.CRYPTOPAY_ASSET,
|
||||
"status": "pending_cryptopay",
|
||||
"description": description,
|
||||
"subscription_duration_months": months,
|
||||
"provider": "cryptopay",
|
||||
},
|
||||
)
|
||||
# Create pending payment in DB and commit to persist
|
||||
try:
|
||||
payment_record = await payment_dal.create_payment_record(
|
||||
session,
|
||||
{
|
||||
"user_id": user_id,
|
||||
"amount": float(amount),
|
||||
"currency": self.settings.CRYPTOPAY_ASSET,
|
||||
"status": "pending_cryptopay",
|
||||
"description": description,
|
||||
"subscription_duration_months": months,
|
||||
"provider": "cryptopay",
|
||||
},
|
||||
)
|
||||
await session.commit()
|
||||
except Exception as e_db_create:
|
||||
await session.rollback()
|
||||
logging.error(
|
||||
f"Failed to create cryptopay payment record for user {user_id}: {e_db_create}",
|
||||
exc_info=True,
|
||||
)
|
||||
return None
|
||||
payload = json.dumps({
|
||||
"user_id": str(user_id),
|
||||
"subscription_months": str(months),
|
||||
@@ -93,12 +103,21 @@ class CryptoPayService:
|
||||
description=description,
|
||||
payload=payload,
|
||||
)
|
||||
await payment_dal.update_provider_payment_and_status(
|
||||
session,
|
||||
payment_record.payment_id,
|
||||
str(invoice.invoice_id),
|
||||
str(invoice.status),
|
||||
)
|
||||
try:
|
||||
await payment_dal.update_provider_payment_and_status(
|
||||
session,
|
||||
payment_record.payment_id,
|
||||
str(invoice.invoice_id),
|
||||
str(invoice.status),
|
||||
)
|
||||
await session.commit()
|
||||
except Exception as e_db_update:
|
||||
await session.rollback()
|
||||
logging.error(
|
||||
f"Failed to update cryptopay payment record {payment_record.payment_id}: {e_db_update}",
|
||||
exc_info=True,
|
||||
)
|
||||
return None
|
||||
return invoice.bot_invoice_url
|
||||
except Exception as e:
|
||||
logging.error(f"CryptoPay invoice creation failed: {e}", exc_info=True)
|
||||
@@ -155,6 +174,7 @@ class CryptoPayService:
|
||||
return
|
||||
|
||||
db_user = await user_dal.get_user_by_id(session, user_id)
|
||||
# Use DB language for user-facing messages
|
||||
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)
|
||||
|
||||
|
||||
@@ -10,7 +10,7 @@ from typing import Optional
|
||||
from config.settings import Settings
|
||||
from .panel_api_service import PanelApiService
|
||||
from bot.middlewares.i18n import JsonI18n
|
||||
from bot.keyboards.inline.user_keyboards import get_subscribe_only_markup
|
||||
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
|
||||
|
||||
@@ -185,7 +185,51 @@ class PanelWebhookService:
|
||||
|
||||
if event_name in EVENT_MAP:
|
||||
days_left, msg_key = EVENT_MAP[event_name]
|
||||
if days_left == 1:
|
||||
# Trigger auto-renew via SubscriptionService (wired in at factory)
|
||||
try:
|
||||
subscription_service = getattr(self, "subscription_service", None)
|
||||
if subscription_service:
|
||||
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':
|
||||
try:
|
||||
ok = await subscription_service.charge_subscription_renewal(session, sub)
|
||||
# If initiation succeeded, suppress the 24h reminder by returning early
|
||||
if ok:
|
||||
await session.commit()
|
||||
return
|
||||
else:
|
||||
await session.rollback()
|
||||
except Exception:
|
||||
await session.rollback()
|
||||
logging.exception("Auto-renew attempt (24h) failed")
|
||||
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
|
||||
if days_left == 2:
|
||||
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)
|
||||
logging.info(
|
||||
"48h webhook check: user_id=%s sub_found=%s auto_renew=%s provider=%s",
|
||||
user_id,
|
||||
bool(sub),
|
||||
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':
|
||||
cancel_kb = get_autorenew_cancel_keyboard(lang, self.i18n)
|
||||
await self._send_message(
|
||||
user_id,
|
||||
lang,
|
||||
"autorenew_48h_charge_tomorrow_notice",
|
||||
reply_markup=cancel_kb,
|
||||
user_name=first_name,
|
||||
)
|
||||
return
|
||||
await self._send_message(
|
||||
user_id,
|
||||
lang,
|
||||
|
||||
@@ -109,8 +109,9 @@ class StarsService:
|
||||
if not final_end:
|
||||
final_end = activation_details["end_date"]
|
||||
|
||||
current_lang = i18n_data.get("current_language",
|
||||
self.settings.DEFAULT_LANGUAGE)
|
||||
# Always use user's language from DB for user-facing messages
|
||||
db_user = await user_dal.get_user_by_id(session, message.from_user.id)
|
||||
current_lang = db_user.language_code if db_user and db_user.language_code else self.settings.DEFAULT_LANGUAGE
|
||||
i18n: JsonI18n = i18n_data.get("i18n_instance")
|
||||
_ = lambda k, **kw: i18n.gettext(current_lang, k, **kw) if i18n else k
|
||||
|
||||
|
||||
@@ -5,7 +5,7 @@ from typing import Optional, Dict, Any, List, Tuple
|
||||
from aiogram import Bot
|
||||
from bot.middlewares.i18n import JsonI18n
|
||||
|
||||
from db.dal import user_dal, subscription_dal, promo_code_dal, payment_dal
|
||||
from db.dal import user_dal, subscription_dal, promo_code_dal, payment_dal, user_billing_dal
|
||||
from bot.utils.date_utils import add_months
|
||||
from db.models import User, Subscription
|
||||
|
||||
@@ -509,6 +509,7 @@ class SubscriptionService:
|
||||
"traffic_limit_bytes": self.settings.user_traffic_limit_bytes,
|
||||
"provider": provider,
|
||||
"skip_notifications": provider == "tribute" and self.settings.TRIBUTE_SKIP_NOTIFICATIONS,
|
||||
"auto_renew_enabled": True,
|
||||
}
|
||||
try:
|
||||
new_or_updated_sub = await subscription_dal.upsert_subscription(
|
||||
@@ -780,6 +781,62 @@ class SubscriptionService:
|
||||
)
|
||||
return results
|
||||
|
||||
async def charge_subscription_renewal(
|
||||
self,
|
||||
session: AsyncSession,
|
||||
sub: Subscription,
|
||||
) -> bool:
|
||||
"""Attempt to charge user using saved payment method. Return True on initiated/handled, False on failure."""
|
||||
if not sub.auto_renew_enabled:
|
||||
return True
|
||||
# If autopayments are disabled globally, skip charging attempts
|
||||
if not getattr(self.settings, 'YOOKASSA_AUTOPAYMENTS_ENABLED', False):
|
||||
return True
|
||||
if sub.provider == "tribute":
|
||||
# Tribute is paid externally; we do not auto-charge here
|
||||
return True
|
||||
|
||||
from db.dal.user_billing_dal import get_user_default_payment_method
|
||||
default_pm = await get_user_default_payment_method(session, sub.user_id)
|
||||
if not default_pm:
|
||||
logging.info(f"Auto-renew skipped: no saved payment method for user {sub.user_id}")
|
||||
return False
|
||||
|
||||
try:
|
||||
from .yookassa_service import YooKassaService # local import to avoid cycles
|
||||
yk: YooKassaService = self.yookassa_service # type: ignore[attr-defined]
|
||||
except Exception:
|
||||
yk = None # type: ignore
|
||||
if not yk or not getattr(yk, 'configured', False):
|
||||
logging.warning("YooKassa unavailable for auto-renew")
|
||||
return False
|
||||
|
||||
months = sub.duration_months or 1
|
||||
amount = self.settings.subscription_options.get(months)
|
||||
if not amount:
|
||||
logging.error(f"Auto-renew price missing for {months} months")
|
||||
return False
|
||||
|
||||
metadata = {
|
||||
"user_id": str(sub.user_id),
|
||||
"auto_renew_for_subscription_id": str(sub.subscription_id),
|
||||
"subscription_months": str(months),
|
||||
}
|
||||
resp = await yk.create_payment(
|
||||
amount=float(amount),
|
||||
currency="RUB",
|
||||
description=f"Auto-renewal for {months} months",
|
||||
metadata=metadata,
|
||||
payment_method_id=default_pm.provider_payment_method_id,
|
||||
save_payment_method=False,
|
||||
capture=True,
|
||||
)
|
||||
if not resp or resp.get("status") not in {"pending", "waiting_for_capture", "succeeded"}:
|
||||
logging.error(f"Auto-renew create_payment failed: {resp}")
|
||||
return False
|
||||
logging.info(f"Auto-renew initiated for user {sub.user_id} payment_id={resp.get('id')}")
|
||||
return True
|
||||
|
||||
async def update_last_notification_sent(
|
||||
self, session: AsyncSession, user_id: int, subscription_end_date: datetime
|
||||
):
|
||||
|
||||
@@ -209,6 +209,7 @@ class TributeService:
|
||||
)
|
||||
|
||||
try:
|
||||
# Use user's DB language in success messages prepared above
|
||||
await bot.send_message(
|
||||
int(user_id),
|
||||
success_msg,
|
||||
|
||||
@@ -61,7 +61,11 @@ class YooKassaService:
|
||||
description: str,
|
||||
metadata: Dict[str, Any],
|
||||
receipt_email: Optional[str] = None,
|
||||
receipt_phone: Optional[str] = None) -> Optional[Dict[str, Any]]:
|
||||
receipt_phone: Optional[str] = None,
|
||||
save_payment_method: bool = False,
|
||||
payment_method_id: Optional[str] = None,
|
||||
capture: bool = True,
|
||||
bind_only: bool = False) -> Optional[Dict[str, Any]]:
|
||||
if not self.configured:
|
||||
logging.error("YooKassa is not configured. Cannot create payment.")
|
||||
return None
|
||||
@@ -102,13 +106,23 @@ class YooKassaService:
|
||||
"value": str(round(amount, 2)),
|
||||
"currency": currency.upper()
|
||||
})
|
||||
builder.set_capture(True)
|
||||
# For binding cards only, do not capture and set minimal amount
|
||||
if bind_only:
|
||||
capture = False
|
||||
amount = max(amount, 1.00)
|
||||
builder.set_capture(capture)
|
||||
builder.set_confirmation({
|
||||
"type": ConfirmationType.REDIRECT,
|
||||
"return_url": self.return_url
|
||||
})
|
||||
builder.set_description(description)
|
||||
builder.set_metadata(metadata)
|
||||
if save_payment_method:
|
||||
# Ask YooKassa to save method for off-session charges
|
||||
builder.set_save_payment_method(True)
|
||||
if payment_method_id:
|
||||
# Use a previously saved payment method for merchant-initiated payments
|
||||
builder.set_payment_method_id(payment_method_id)
|
||||
|
||||
receipt_items_list: List[Dict[str, Any]] = [{
|
||||
"description":
|
||||
@@ -122,9 +136,9 @@ class YooKassaService:
|
||||
"vat_code":
|
||||
str(self.settings.YOOKASSA_VAT_CODE),
|
||||
"payment_mode":
|
||||
self.settings.YOOKASSA_PAYMENT_MODE,
|
||||
getattr(self.settings, 'yk_receipt_payment_mode', self.settings.YOOKASSA_PAYMENT_MODE),
|
||||
"payment_subject":
|
||||
self.settings.YOOKASSA_PAYMENT_SUBJECT
|
||||
getattr(self.settings, 'yk_receipt_payment_subject', self.settings.YOOKASSA_PAYMENT_SUBJECT)
|
||||
}]
|
||||
|
||||
receipt_data_dict: Dict[str, Any] = {
|
||||
@@ -178,7 +192,8 @@ class YooKassaService:
|
||||
"description_from_yk":
|
||||
response.description,
|
||||
"test_mode":
|
||||
response.test if hasattr(response, 'test') else None
|
||||
response.test if hasattr(response, 'test') else None,
|
||||
"payment_method": getattr(response, 'payment_method', None),
|
||||
}
|
||||
except Exception as e:
|
||||
logging.error(f"YooKassa payment creation failed: {e}",
|
||||
@@ -204,37 +219,40 @@ class YooKassaService:
|
||||
logging.info(
|
||||
f"YooKassa payment info for {payment_id_in_yookassa}: Status={payment_info_yk.status}, Paid={payment_info_yk.paid}"
|
||||
)
|
||||
pm = getattr(payment_info_yk, 'payment_method', None)
|
||||
pm_payload: Dict[str, Any] = {}
|
||||
if pm:
|
||||
# Collect common fields, including id and hints for last4
|
||||
pm_id = getattr(pm, 'id', None)
|
||||
pm_type = getattr(pm, 'type', None)
|
||||
pm_title = getattr(pm, 'title', None)
|
||||
account_number = getattr(pm, 'account_number', None) or getattr(pm, 'account', None)
|
||||
card_obj = getattr(pm, 'card', None)
|
||||
last4_val = None
|
||||
if card_obj and hasattr(card_obj, 'last4'):
|
||||
last4_val = getattr(card_obj, 'last4')
|
||||
elif isinstance(account_number, str) and len(account_number) >= 4:
|
||||
last4_val = account_number[-4:]
|
||||
pm_payload = {
|
||||
"id": pm_id,
|
||||
"type": pm_type,
|
||||
"title": pm_title,
|
||||
"card_last4": last4_val,
|
||||
}
|
||||
return {
|
||||
"id":
|
||||
payment_info_yk.id,
|
||||
"status":
|
||||
payment_info_yk.status,
|
||||
"paid":
|
||||
payment_info_yk.paid,
|
||||
"amount_value":
|
||||
float(payment_info_yk.amount.value),
|
||||
"amount_currency":
|
||||
payment_info_yk.amount.currency,
|
||||
"metadata":
|
||||
payment_info_yk.metadata,
|
||||
"description":
|
||||
payment_info_yk.description,
|
||||
"refundable":
|
||||
payment_info_yk.refundable,
|
||||
"created_at":
|
||||
payment_info_yk.created_at.isoformat() if hasattr(
|
||||
payment_info_yk.created_at, 'isoformat') else str(
|
||||
payment_info_yk.created_at),
|
||||
"captured_at":
|
||||
payment_info_yk.captured_at.isoformat()
|
||||
if payment_info_yk.captured_at and hasattr(
|
||||
payment_info_yk.captured_at, 'isoformat') else None,
|
||||
"payment_method_type":
|
||||
payment_info_yk.payment_method.type
|
||||
if payment_info_yk.payment_method else None,
|
||||
"test_mode":
|
||||
payment_info_yk.test
|
||||
if hasattr(payment_info_yk, 'test') else None
|
||||
"id": payment_info_yk.id,
|
||||
"status": payment_info_yk.status,
|
||||
"paid": payment_info_yk.paid,
|
||||
"amount_value": float(payment_info_yk.amount.value),
|
||||
"amount_currency": payment_info_yk.amount.currency,
|
||||
"metadata": payment_info_yk.metadata,
|
||||
"description": payment_info_yk.description,
|
||||
"refundable": payment_info_yk.refundable,
|
||||
"created_at": payment_info_yk.created_at.isoformat() if hasattr(
|
||||
payment_info_yk.created_at, 'isoformat') else str(payment_info_yk.created_at),
|
||||
"captured_at": payment_info_yk.captured_at.isoformat() if getattr(payment_info_yk, 'captured_at', None) and hasattr(payment_info_yk.captured_at, 'isoformat') else None,
|
||||
"payment_method": pm_payload,
|
||||
"test_mode": getattr(payment_info_yk, 'test', None),
|
||||
}
|
||||
else:
|
||||
logging.warning(
|
||||
@@ -246,3 +264,16 @@ class YooKassaService:
|
||||
f"YooKassa get payment info for {payment_id_in_yookassa} failed: {e}",
|
||||
exc_info=True)
|
||||
return None
|
||||
|
||||
async def cancel_payment(self, payment_id_in_yookassa: str) -> bool:
|
||||
if not self.configured:
|
||||
logging.error("YooKassa is not configured. Cannot cancel payment.")
|
||||
return False
|
||||
try:
|
||||
loop = asyncio.get_running_loop()
|
||||
await loop.run_in_executor(None, lambda: YooKassaPayment.cancel(payment_id_in_yookassa))
|
||||
logging.info(f"Cancelled YooKassa payment {payment_id_in_yookassa}")
|
||||
return True
|
||||
except Exception as e:
|
||||
logging.error(f"Failed to cancel YooKassa payment {payment_id_in_yookassa}: {e}")
|
||||
return False
|
||||
|
||||
@@ -28,3 +28,8 @@ class AdminStates(StatesGroup):
|
||||
waiting_for_user_search = State()
|
||||
waiting_for_subscription_days_to_add = State()
|
||||
waiting_for_direct_message_to_user = State()
|
||||
|
||||
# Ads campaigns
|
||||
waiting_for_ad_source = State()
|
||||
waiting_for_ad_start_param = State()
|
||||
waiting_for_ad_cost = State()
|
||||
|
||||
@@ -30,8 +30,11 @@ class Settings(BaseSettings):
|
||||
|
||||
YOOKASSA_DEFAULT_RECEIPT_EMAIL: Optional[str] = Field(default=None)
|
||||
YOOKASSA_VAT_CODE: int = Field(default=1)
|
||||
# Deprecated: explicit receipt fields are now derived from YOOKASSA_AUTOPAYMENTS_ENABLED
|
||||
YOOKASSA_PAYMENT_MODE: str = Field(default="full_prepayment")
|
||||
YOOKASSA_PAYMENT_SUBJECT: str = Field(default="service")
|
||||
# Single toggle to enable recurring payments (saving cards, managing payment methods, auto-renew)
|
||||
YOOKASSA_AUTOPAYMENTS_ENABLED: bool = Field(default=False)
|
||||
|
||||
WEBHOOK_BASE_URL: Optional[str] = None
|
||||
|
||||
@@ -233,6 +236,19 @@ class Settings(BaseSettings):
|
||||
return f"{base.rstrip('/')}{self.cryptopay_webhook_path}"
|
||||
return None
|
||||
|
||||
# Computed YooKassa receipt fields based on recurring toggle
|
||||
@computed_field
|
||||
@property
|
||||
def yk_receipt_payment_mode(self) -> str:
|
||||
# If autopayments are enabled, use service; otherwise full prepayment
|
||||
return "service" if self.YOOKASSA_AUTOPAYMENTS_ENABLED else "full_prepayment"
|
||||
|
||||
@computed_field
|
||||
@property
|
||||
def yk_receipt_payment_subject(self) -> str:
|
||||
# If autopayments are enabled, use full_payment; otherwise payment
|
||||
return "full_payment" if self.YOOKASSA_AUTOPAYMENTS_ENABLED else "payment"
|
||||
|
||||
@computed_field
|
||||
@property
|
||||
def subscription_options(self) -> Dict[int, float]:
|
||||
|
||||
@@ -0,0 +1,21 @@
|
||||
from . import user_dal
|
||||
from . import payment_dal
|
||||
from . import subscription_dal
|
||||
from . import promo_code_dal
|
||||
from . import panel_sync_dal
|
||||
from . import message_log_dal
|
||||
from . import user_billing_dal
|
||||
from . import ad_dal
|
||||
|
||||
__all__ = (
|
||||
"user_dal",
|
||||
"payment_dal",
|
||||
"subscription_dal",
|
||||
"promo_code_dal",
|
||||
"panel_sync_dal",
|
||||
"message_log_dal",
|
||||
"user_billing_dal",
|
||||
"ad_dal",
|
||||
)
|
||||
|
||||
|
||||
@@ -0,0 +1,164 @@
|
||||
import logging
|
||||
from typing import Optional, List, Dict, Any, Tuple
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from sqlalchemy.future import select
|
||||
from sqlalchemy.orm import selectinload
|
||||
from sqlalchemy import update, delete, func, and_
|
||||
|
||||
from ..models import AdCampaign, AdAttribution, Payment
|
||||
|
||||
|
||||
async def create_campaign(
|
||||
session: AsyncSession, *, source: str, start_param: str, cost: float
|
||||
) -> AdCampaign:
|
||||
existing = await get_campaign_by_start_param(session, start_param)
|
||||
if existing:
|
||||
raise ValueError("ad_campaign_start_param_exists")
|
||||
|
||||
campaign = AdCampaign(source=source, start_param=start_param, cost=float(cost))
|
||||
session.add(campaign)
|
||||
await session.flush()
|
||||
await session.refresh(campaign)
|
||||
logging.info(
|
||||
f"AdCampaign created id={campaign.ad_campaign_id}, source={source}, start={start_param}, cost={cost}"
|
||||
)
|
||||
return campaign
|
||||
|
||||
|
||||
async def get_campaign_by_id(session: AsyncSession, campaign_id: int) -> Optional[AdCampaign]:
|
||||
stmt = select(AdCampaign).where(AdCampaign.ad_campaign_id == campaign_id)
|
||||
result = await session.execute(stmt)
|
||||
return result.scalar_one_or_none()
|
||||
|
||||
|
||||
async def get_campaign_by_start_param(session: AsyncSession, start_param: str) -> Optional[AdCampaign]:
|
||||
clean = start_param.strip()
|
||||
stmt = select(AdCampaign).where(AdCampaign.start_param == clean)
|
||||
result = await session.execute(stmt)
|
||||
return result.scalar_one_or_none()
|
||||
|
||||
|
||||
async def list_campaigns(session: AsyncSession, *, only_active: bool = False) -> List[AdCampaign]:
|
||||
stmt = select(AdCampaign).order_by(AdCampaign.created_at.desc())
|
||||
if only_active:
|
||||
stmt = stmt.where(AdCampaign.is_active == True)
|
||||
result = await session.execute(stmt)
|
||||
return result.scalars().all()
|
||||
|
||||
|
||||
async def toggle_campaign_active(session: AsyncSession, campaign_id: int, is_active: bool) -> bool:
|
||||
stmt = (
|
||||
update(AdCampaign)
|
||||
.where(AdCampaign.ad_campaign_id == campaign_id)
|
||||
.values(is_active=is_active)
|
||||
)
|
||||
result = await session.execute(stmt)
|
||||
return result.rowcount > 0
|
||||
|
||||
|
||||
async def ensure_attribution(session: AsyncSession, *, user_id: int, campaign_id: int) -> AdAttribution:
|
||||
existing = await get_attribution_for_user(session, user_id)
|
||||
if existing:
|
||||
return existing
|
||||
attrib = AdAttribution(user_id=user_id, ad_campaign_id=campaign_id)
|
||||
session.add(attrib)
|
||||
await session.flush()
|
||||
await session.refresh(attrib)
|
||||
logging.info(f"AdAttribution created for user {user_id} -> campaign {campaign_id}")
|
||||
return attrib
|
||||
|
||||
|
||||
async def get_attribution_for_user(session: AsyncSession, user_id: int) -> Optional[AdAttribution]:
|
||||
stmt = select(AdAttribution).where(AdAttribution.user_id == user_id)
|
||||
result = await session.execute(stmt)
|
||||
return result.scalar_one_or_none()
|
||||
|
||||
|
||||
async def mark_trial_activated(session: AsyncSession, user_id: int) -> bool:
|
||||
stmt = (
|
||||
update(AdAttribution)
|
||||
.where(and_(AdAttribution.user_id == user_id, AdAttribution.trial_activated_at.is_(None)))
|
||||
.values(trial_activated_at=func.now())
|
||||
)
|
||||
result = await session.execute(stmt)
|
||||
return result.rowcount > 0
|
||||
|
||||
|
||||
async def get_campaign_stats(session: AsyncSession, campaign_id: int) -> Dict[str, Any]:
|
||||
# Starts (attributed users)
|
||||
starts_stmt = select(func.count(AdAttribution.user_id)).where(
|
||||
AdAttribution.ad_campaign_id == campaign_id
|
||||
)
|
||||
starts = (await session.execute(starts_stmt)).scalar() or 0
|
||||
|
||||
# Trials
|
||||
trials_stmt = select(func.count(AdAttribution.user_id)).where(
|
||||
and_(AdAttribution.ad_campaign_id == campaign_id, AdAttribution.trial_activated_at.is_not(None))
|
||||
)
|
||||
trials = (await session.execute(trials_stmt)).scalar() or 0
|
||||
|
||||
# Payers (unique users with succeeded payments)
|
||||
payers_stmt = select(func.count(func.distinct(Payment.user_id))).select_from(Payment).where(
|
||||
and_(
|
||||
Payment.status == "succeeded",
|
||||
Payment.user_id.in_(
|
||||
select(AdAttribution.user_id).where(AdAttribution.ad_campaign_id == campaign_id)
|
||||
),
|
||||
)
|
||||
)
|
||||
payers = (await session.execute(payers_stmt)).scalar() or 0
|
||||
|
||||
# Revenue sum
|
||||
revenue_stmt = select(func.coalesce(func.sum(Payment.amount), 0.0)).select_from(Payment).where(
|
||||
and_(
|
||||
Payment.status == "succeeded",
|
||||
Payment.user_id.in_(
|
||||
select(AdAttribution.user_id).where(AdAttribution.ad_campaign_id == campaign_id)
|
||||
),
|
||||
)
|
||||
)
|
||||
revenue = float((await session.execute(revenue_stmt)).scalar() or 0.0)
|
||||
|
||||
return {
|
||||
"starts": int(starts),
|
||||
"trials": int(trials),
|
||||
"payers": int(payers),
|
||||
"revenue": revenue,
|
||||
}
|
||||
|
||||
|
||||
async def count_campaigns(session: AsyncSession, *, only_active: bool = False) -> int:
|
||||
stmt = select(func.count(AdCampaign.ad_campaign_id))
|
||||
if only_active:
|
||||
stmt = stmt.where(AdCampaign.is_active == True)
|
||||
return int((await session.execute(stmt)).scalar() or 0)
|
||||
|
||||
|
||||
async def list_campaigns_paged(
|
||||
session: AsyncSession, *, page: int, page_size: int, only_active: bool = False
|
||||
) -> List[AdCampaign]:
|
||||
offset = max(0, page) * max(1, page_size)
|
||||
stmt = select(AdCampaign).order_by(AdCampaign.created_at.desc()).offset(offset).limit(page_size)
|
||||
if only_active:
|
||||
stmt = stmt.where(AdCampaign.is_active == True)
|
||||
result = await session.execute(stmt)
|
||||
return result.scalars().all()
|
||||
|
||||
|
||||
async def get_totals(session: AsyncSession) -> Dict[str, float]:
|
||||
# Total cost across all campaigns
|
||||
total_cost_stmt = select(func.coalesce(func.sum(AdCampaign.cost), 0.0))
|
||||
total_cost = float((await session.execute(total_cost_stmt)).scalar() or 0.0)
|
||||
|
||||
# Total revenue from all attributed users (unique users counted across all campaigns)
|
||||
revenue_stmt = select(func.coalesce(func.sum(Payment.amount), 0.0)).select_from(Payment).where(
|
||||
and_(
|
||||
Payment.status == "succeeded",
|
||||
Payment.user_id.in_(select(AdAttribution.user_id)),
|
||||
)
|
||||
)
|
||||
total_revenue = float((await session.execute(revenue_stmt)).scalar() or 0.0)
|
||||
|
||||
return {"cost": total_cost, "revenue": total_revenue}
|
||||
|
||||
|
||||
@@ -53,6 +53,11 @@ async def update_subscription(
|
||||
return sub
|
||||
|
||||
|
||||
async def set_auto_renew(session: AsyncSession, subscription_id: int, enabled: bool) -> Optional[Subscription]:
|
||||
"""Toggle auto_renew_enabled for a subscription."""
|
||||
return await update_subscription(session, subscription_id, {"auto_renew_enabled": enabled})
|
||||
|
||||
|
||||
async def set_user_subscriptions_cancelled_with_grace(
|
||||
session: AsyncSession, user_id: int, grace_days: int = 1) -> int:
|
||||
"""Mark all active user subscriptions as cancelled with a short grace period.
|
||||
|
||||
@@ -0,0 +1,164 @@
|
||||
from typing import Optional, Dict, Any, List
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from sqlalchemy import select, update
|
||||
from sqlalchemy.sql import func
|
||||
|
||||
from db.models import UserBilling, UserPaymentMethod
|
||||
|
||||
|
||||
async def get_user_billing(session: AsyncSession, user_id: int) -> Optional[UserBilling]:
|
||||
stmt = select(UserBilling).where(UserBilling.user_id == user_id)
|
||||
result = await session.execute(stmt)
|
||||
return result.scalar_one_or_none()
|
||||
|
||||
|
||||
async def upsert_yk_payment_method(
|
||||
session: AsyncSession,
|
||||
*,
|
||||
user_id: int,
|
||||
payment_method_id: str,
|
||||
card_last4: Optional[str] = None,
|
||||
card_network: Optional[str] = None,
|
||||
) -> UserBilling:
|
||||
existing = await get_user_billing(session, user_id)
|
||||
if existing:
|
||||
existing.yookassa_payment_method_id = payment_method_id
|
||||
existing.card_last4 = card_last4
|
||||
existing.card_network = card_network
|
||||
existing.updated_at = func.now()
|
||||
await session.flush()
|
||||
await session.refresh(existing)
|
||||
return existing
|
||||
record = UserBilling(
|
||||
user_id=user_id,
|
||||
yookassa_payment_method_id=payment_method_id,
|
||||
card_last4=card_last4,
|
||||
card_network=card_network,
|
||||
)
|
||||
session.add(record)
|
||||
await session.flush()
|
||||
await session.refresh(record)
|
||||
return record
|
||||
|
||||
|
||||
async def delete_yk_payment_method(session: AsyncSession, user_id: int) -> bool:
|
||||
existing = await get_user_billing(session, user_id)
|
||||
if not existing:
|
||||
return False
|
||||
existing.yookassa_payment_method_id = None
|
||||
existing.card_last4 = None
|
||||
existing.card_network = None
|
||||
existing.updated_at = func.now()
|
||||
await session.flush()
|
||||
await session.refresh(existing)
|
||||
return True
|
||||
|
||||
|
||||
# Multi-card support API
|
||||
async def upsert_user_payment_method(
|
||||
session: AsyncSession,
|
||||
*,
|
||||
user_id: int,
|
||||
provider_payment_method_id: str,
|
||||
provider: str = "yookassa",
|
||||
card_last4: Optional[str] = None,
|
||||
card_network: Optional[str] = None,
|
||||
set_default: bool = False,
|
||||
) -> UserPaymentMethod:
|
||||
existing_stmt = select(UserPaymentMethod).where(UserPaymentMethod.provider_payment_method_id == provider_payment_method_id)
|
||||
result = await session.execute(existing_stmt)
|
||||
existing: Optional[UserPaymentMethod] = result.scalar_one_or_none()
|
||||
if existing:
|
||||
existing.card_last4 = card_last4
|
||||
existing.card_network = card_network
|
||||
if set_default:
|
||||
# unset previous defaults
|
||||
await session.execute(
|
||||
update(UserPaymentMethod)
|
||||
.where(UserPaymentMethod.user_id == user_id)
|
||||
.values(is_default=False)
|
||||
)
|
||||
existing.is_default = True
|
||||
existing.updated_at = func.now()
|
||||
await session.flush()
|
||||
await session.refresh(existing)
|
||||
return existing
|
||||
if set_default:
|
||||
await session.execute(
|
||||
update(UserPaymentMethod)
|
||||
.where(UserPaymentMethod.user_id == user_id)
|
||||
.values(is_default=False)
|
||||
)
|
||||
record = UserPaymentMethod(
|
||||
user_id=user_id,
|
||||
provider=provider,
|
||||
provider_payment_method_id=provider_payment_method_id,
|
||||
card_last4=card_last4,
|
||||
card_network=card_network,
|
||||
is_default=set_default,
|
||||
)
|
||||
session.add(record)
|
||||
await session.flush()
|
||||
await session.refresh(record)
|
||||
return record
|
||||
|
||||
|
||||
async def list_user_payment_methods(session: AsyncSession, user_id: int, provider: Optional[str] = None) -> List[UserPaymentMethod]:
|
||||
stmt = select(UserPaymentMethod).where(UserPaymentMethod.user_id == user_id)
|
||||
if provider:
|
||||
stmt = stmt.where(UserPaymentMethod.provider == provider)
|
||||
stmt = stmt.order_by(UserPaymentMethod.is_default.desc(), UserPaymentMethod.created_at.desc())
|
||||
result = await session.execute(stmt)
|
||||
return result.scalars().all()
|
||||
|
||||
|
||||
async def get_user_default_payment_method(session: AsyncSession, user_id: int, provider: str = "yookassa") -> Optional[UserPaymentMethod]:
|
||||
stmt = select(UserPaymentMethod).where(
|
||||
UserPaymentMethod.user_id == user_id,
|
||||
UserPaymentMethod.provider == provider,
|
||||
UserPaymentMethod.is_default == True,
|
||||
)
|
||||
result = await session.execute(stmt)
|
||||
return result.scalar_one_or_none()
|
||||
|
||||
|
||||
async def set_user_default_payment_method(session: AsyncSession, user_id: int, method_id: int) -> bool:
|
||||
methods = await list_user_payment_methods(session, user_id)
|
||||
if not any(m.method_id == method_id for m in methods):
|
||||
return False
|
||||
await session.execute(update(UserPaymentMethod).where(UserPaymentMethod.user_id == user_id).values(is_default=False))
|
||||
await session.execute(update(UserPaymentMethod).where(UserPaymentMethod.method_id == method_id).values(is_default=True))
|
||||
return True
|
||||
|
||||
|
||||
async def delete_user_payment_method(session: AsyncSession, user_id: int, method_id: int) -> bool:
|
||||
stmt = select(UserPaymentMethod).where(UserPaymentMethod.method_id == method_id, UserPaymentMethod.user_id == user_id)
|
||||
result = await session.execute(stmt)
|
||||
method = result.scalar_one_or_none()
|
||||
if not method:
|
||||
return False
|
||||
await session.delete(method)
|
||||
await session.flush()
|
||||
return True
|
||||
|
||||
|
||||
async def delete_user_payment_method_by_provider_id(
|
||||
session: AsyncSession,
|
||||
user_id: int,
|
||||
provider_payment_method_id: str,
|
||||
) -> bool:
|
||||
"""Delete a saved payment method by its provider payment_method.id for a specific user.
|
||||
|
||||
Useful when callbacks pass the provider id (e.g., YooKassa pm_...) instead of our internal method_id.
|
||||
"""
|
||||
stmt = select(UserPaymentMethod).where(
|
||||
UserPaymentMethod.user_id == user_id,
|
||||
UserPaymentMethod.provider_payment_method_id == provider_payment_method_id,
|
||||
)
|
||||
result = await session.execute(stmt)
|
||||
method: Optional[UserPaymentMethod] = result.scalar_one_or_none()
|
||||
if not method:
|
||||
return False
|
||||
await session.delete(method)
|
||||
await session.flush()
|
||||
return True
|
||||
@@ -4,6 +4,7 @@ from sqlalchemy.orm import sessionmaker
|
||||
|
||||
from config.settings import Settings
|
||||
from .models import Base
|
||||
from .migrator import run_simple_migrations
|
||||
|
||||
async_engine = None
|
||||
|
||||
@@ -62,6 +63,8 @@ async def init_db(settings: Settings, session_factory: sessionmaker):
|
||||
|
||||
async with async_engine.begin() as conn:
|
||||
await conn.run_sync(Base.metadata.create_all)
|
||||
# Run lightweight, idempotent migrations to add any missing columns
|
||||
await conn.run_sync(run_simple_migrations)
|
||||
logging.info(
|
||||
"PostgreSQL database initialized/checked successfully using SQLAlchemy."
|
||||
)
|
||||
|
||||
@@ -0,0 +1,66 @@
|
||||
import logging
|
||||
from typing import Set
|
||||
|
||||
from sqlalchemy import inspect, text
|
||||
from sqlalchemy.engine import Connection
|
||||
|
||||
from .models import Base
|
||||
|
||||
|
||||
def _add_missing_columns(connection: Connection) -> None:
|
||||
inspector = inspect(connection)
|
||||
metadata = Base.metadata
|
||||
|
||||
existing_tables: Set[str] = set(inspector.get_table_names())
|
||||
|
||||
for table in metadata.tables.values():
|
||||
table_name = table.name
|
||||
if table_name not in existing_tables:
|
||||
# Tables are created elsewhere via create_all; skip here.
|
||||
continue
|
||||
|
||||
existing_columns = {col_info["name"] for col_info in inspector.get_columns(table_name)}
|
||||
|
||||
for desired_column in table.columns:
|
||||
if desired_column.name in existing_columns:
|
||||
continue
|
||||
|
||||
# Build ADD COLUMN DDL
|
||||
preparer = connection.dialect.identifier_preparer
|
||||
table_quoted = preparer.format_table(table)
|
||||
column_name_quoted = preparer.quote(desired_column.name)
|
||||
column_type_sql = desired_column.type.compile(dialect=connection.dialect)
|
||||
|
||||
default_clause = ""
|
||||
server_default = getattr(desired_column, "server_default", None)
|
||||
if server_default is not None and getattr(server_default, "arg", None) is not None:
|
||||
try:
|
||||
compiled_default = str(
|
||||
server_default.arg.compile(dialect=connection.dialect)
|
||||
)
|
||||
default_clause = f" DEFAULT {compiled_default}"
|
||||
except Exception: # best-effort
|
||||
pass
|
||||
|
||||
# For safety, add new columns as NULLable to avoid failures on existing rows
|
||||
# If strict NOT NULL is needed, it can be enforced manually later.
|
||||
ddl = f"ALTER TABLE {table_quoted} ADD COLUMN {column_name_quoted} {column_type_sql}{default_clause}"
|
||||
|
||||
logging.info(
|
||||
f"Migrator: adding missing column {desired_column.name} to table {table_name}"
|
||||
)
|
||||
connection.execute(text(ddl))
|
||||
|
||||
|
||||
def run_simple_migrations(connection: Connection) -> None:
|
||||
"""
|
||||
Run lightweight, idempotent migrations:
|
||||
- Ensure missing columns are added to existing tables to match models in db/models.py
|
||||
Note: Table creation is handled separately via Base.metadata.create_all.
|
||||
"""
|
||||
try:
|
||||
_add_missing_columns(connection)
|
||||
logging.info("Migrator: schema synchronized (columns added as needed).")
|
||||
except Exception as e:
|
||||
logging.error(f"Migrator: failed to run simple migrations: {e}", exc_info=True)
|
||||
raise
|
||||
@@ -72,6 +72,7 @@ class Subscription(Base):
|
||||
last_notification_sent = Column(DateTime(timezone=True), nullable=True)
|
||||
provider = Column(String, nullable=True)
|
||||
skip_notifications = Column(Boolean, default=False)
|
||||
auto_renew_enabled = Column(Boolean, default=True, index=True)
|
||||
|
||||
user = relationship("User", back_populates="subscriptions")
|
||||
|
||||
@@ -112,6 +113,37 @@ class Payment(Base):
|
||||
back_populates="payments_where_used")
|
||||
|
||||
|
||||
class UserBilling(Base):
|
||||
__tablename__ = "user_billing"
|
||||
|
||||
user_id = Column(BigInteger, ForeignKey("users.user_id"), primary_key=True)
|
||||
# Saved payment method for off-session recurring charges (YooKassa)
|
||||
yookassa_payment_method_id = Column(String, nullable=True, unique=True)
|
||||
card_last4 = Column(String, nullable=True)
|
||||
card_network = Column(String, nullable=True)
|
||||
created_at = Column(DateTime(timezone=True), server_default=func.now())
|
||||
updated_at = Column(DateTime(timezone=True), onupdate=func.now(), nullable=True)
|
||||
|
||||
user = relationship("User")
|
||||
|
||||
class UserPaymentMethod(Base):
|
||||
__tablename__ = "user_payment_methods"
|
||||
|
||||
method_id = Column(Integer, primary_key=True, autoincrement=True)
|
||||
user_id = Column(BigInteger, ForeignKey("users.user_id"), nullable=False, index=True)
|
||||
provider = Column(String, nullable=False, default="yookassa", index=True)
|
||||
provider_payment_method_id = Column(String, nullable=False, unique=True, index=True)
|
||||
card_last4 = Column(String, nullable=True)
|
||||
card_network = Column(String, nullable=True)
|
||||
is_default = Column(Boolean, default=False, index=True)
|
||||
created_at = Column(DateTime(timezone=True), server_default=func.now())
|
||||
updated_at = Column(DateTime(timezone=True), onupdate=func.now(), nullable=True)
|
||||
|
||||
user = relationship("User")
|
||||
__table_args__ = (
|
||||
UniqueConstraint('user_id', 'provider_payment_method_id', name='uq_user_provider_method'),
|
||||
)
|
||||
|
||||
class PromoCode(Base):
|
||||
__tablename__ = "promo_codes"
|
||||
|
||||
@@ -195,3 +227,35 @@ class PanelSyncStatus(Base):
|
||||
subscriptions_synced = Column(Integer, default=0)
|
||||
|
||||
__table_args__ = (UniqueConstraint('id'), )
|
||||
|
||||
|
||||
class AdCampaign(Base):
|
||||
__tablename__ = "ad_campaigns"
|
||||
|
||||
ad_campaign_id = Column(Integer, primary_key=True, autoincrement=True)
|
||||
source = Column(String, nullable=False, index=True)
|
||||
start_param = Column(String, nullable=False, unique=True, index=True)
|
||||
cost = Column(Float, nullable=False, default=0.0)
|
||||
is_active = Column(Boolean, default=True, index=True)
|
||||
created_at = Column(DateTime(timezone=True), server_default=func.now())
|
||||
|
||||
attributions = relationship(
|
||||
"AdAttribution",
|
||||
back_populates="campaign",
|
||||
cascade="all, delete-orphan",
|
||||
)
|
||||
|
||||
def __repr__(self):
|
||||
return f"<AdCampaign(id={self.ad_campaign_id}, source='{self.source}', start_param='{self.start_param}', cost={self.cost})>"
|
||||
|
||||
|
||||
class AdAttribution(Base):
|
||||
__tablename__ = "ad_attributions"
|
||||
|
||||
user_id = Column(BigInteger, ForeignKey("users.user_id"), primary_key=True, index=True)
|
||||
ad_campaign_id = Column(Integer, ForeignKey("ad_campaigns.ad_campaign_id"), nullable=False, index=True)
|
||||
first_start_at = Column(DateTime(timezone=True), server_default=func.now())
|
||||
trial_activated_at = Column(DateTime(timezone=True), nullable=True)
|
||||
|
||||
user = relationship("User")
|
||||
campaign = relationship("AdCampaign", back_populates="attributions")
|
||||
|
||||
@@ -11,6 +11,9 @@ services:
|
||||
volumes:
|
||||
- ./locales:/app/locales
|
||||
restart: unless-stopped
|
||||
depends_on:
|
||||
remnawave-tg-shop-db:
|
||||
condition: service_healthy
|
||||
|
||||
remnawave-tg-shop-db:
|
||||
image: postgres:17
|
||||
@@ -23,6 +26,11 @@ services:
|
||||
networks:
|
||||
- remnawave-network
|
||||
restart: unless-stopped
|
||||
healthcheck:
|
||||
test: ["CMD-SHELL", "pg_isready -U $$POSTGRES_USER -d $$POSTGRES_DB"]
|
||||
interval: 5s
|
||||
timeout: 5s
|
||||
retries: 20
|
||||
|
||||
networks:
|
||||
remnawave-network:
|
||||
|
||||
+49
-1
@@ -5,7 +5,9 @@
|
||||
"menu_activate_trial_button": "🆓 Free Trial",
|
||||
"menu_subscribe_inline": "🚀 Purchase",
|
||||
"menu_my_subscription_inline": "🔐 My Subscription",
|
||||
"no_subscription options available": "Subscription issuance is not configured by the bot administrator",
|
||||
"menu_referral_inline": "🎁 Referrals",
|
||||
"referral_no_bonuses_configured": "Sorry, the referral program is currently disabled",
|
||||
"menu_apply_promo_button": "🎟 Promo Code",
|
||||
"menu_language_settings_inline": "🌐 Language",
|
||||
"menu_server_status_button": "📊 Status",
|
||||
@@ -209,8 +211,12 @@
|
||||
"subscription_24h_notification": "👋 Hi, {user_name}!\n\n⏳ Your VPN subscription expires in 1 day — {end_date}.\n\nPlease renew it using the button below.",
|
||||
"subscription_expired_notification": "👋 Hi, {user_name}!\n\n⛔ Your VPN subscription expired on {end_date}.\n\nPlease renew it using the button below.",
|
||||
"subscription_expired_yesterday_notification": "👋 Hi, {user_name}!\n\n⏳ Your VPN subscription expired yesterday ({end_date}).\n\nPlease renew it using the button below.",
|
||||
"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:",
|
||||
"admin_user_reset_trial_button": "🔄 Reset Trial",
|
||||
@@ -371,6 +377,31 @@
|
||||
"admin_sync_not_found_in_db": "\n❌ Not found in DB: {count}",
|
||||
"admin_payments_pagination_info": "📊 Showing {shown} of {total} payments (page {current_page}/{total_pages})",
|
||||
"my_subscription_details": "🔐 <b>My Subscription</b>\n\n⏰ Status: <b>{status}</b>\n📅 Active until: <b>{end_date}</b>\n📆 Days left: <b>{days_left}</b>\n\n🔗 Configuration link:\n<code>{config_link}</code>\n\n📊 Traffic:\nLimit: <b>{traffic_limit}</b>\nUsed: <b>{traffic_used}</b>",
|
||||
"autorenew_enable_button": "🔄 Enable auto-renew",
|
||||
"autorenew_disable_button": "🛑 Disable auto-renew",
|
||||
"subscription_autorenew_updated": "Auto-renew settings updated.",
|
||||
"payment_methods_manage_button": "💳 Payment Methods",
|
||||
"payment_methods_title": "💳 <b>Payment Methods</b>",
|
||||
"payment_method_bind_button": "➕ Add method",
|
||||
"payment_method_delete_button": "🗑 Remove",
|
||||
"payment_method_view_button": "ℹ️ Details",
|
||||
"payment_method_none": "You don't have a saved payment method yet.",
|
||||
"payment_method_bound_success": "✅ Payment method added.",
|
||||
"payment_method_deleted_success": "✅ Payment method removed.",
|
||||
"payment_method_delete_confirm": "Remove saved payment method?",
|
||||
"payment_method_card_title": "💳 {network} ••••{last4}",
|
||||
"payment_method_generic_title": "💳 {network}",
|
||||
"payment_method_wallet_title": "💼 YooMoney wallet ••••{last4}",
|
||||
"payment_network_card": "Card",
|
||||
"payment_network_generic": "Payment method",
|
||||
"payment_method_added_at": "Added: {date}",
|
||||
"payment_method_last_tx": "Last transaction: {date}",
|
||||
"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.",
|
||||
"subscription_not_active": "You don't have an active subscription.",
|
||||
"error_service_unavailable": "Service unavailable. Please try again later.",
|
||||
"error_payment_gateway": "Payment service error. Please try again later.",
|
||||
@@ -380,5 +411,22 @@
|
||||
"error_creating_payment_record": "Error creating payment record. Please try again later.",
|
||||
"error_payment_gateway_link_failed": "Error creating payment link. Please try again later.",
|
||||
"status_active": "Active",
|
||||
"status_inactive": "Inactive"
|
||||
"status_inactive": "Inactive",
|
||||
"admin_ads_section": "📈 Ads",
|
||||
"admin_ads_header": "📈 Ad Campaigns:",
|
||||
"admin_ads_empty": "📭 No ad campaigns. Click \"Create\" to add one.",
|
||||
"admin_ads_item": "ID: {id}\nSource: <b>{source}</b>\nstart={start_param}\nCost: {cost} RUB\nActive: {active}\n— Starts: {starts}\n— Trials: {trials}\n— Payers: {payers}\n— Revenue: {revenue} RUB",
|
||||
"admin_ads_create_button": "➕ Create campaign",
|
||||
"admin_ads_create_source_prompt": "Enter source (e.g., AEZA, VK, TG-channel):",
|
||||
"admin_ads_create_start_param_prompt": "Enter start link parameter (e.g., AEZA). Will be used as start=AEZA",
|
||||
"admin_ads_create_cost_prompt": "Enter campaign cost (RUB):",
|
||||
"admin_ads_invalid_source": "❌ Invalid source. Enter up to 64 characters.",
|
||||
"admin_ads_invalid_start_param": "❌ Invalid parameter. Allowed letters/digits/underscore/dash (2-64).",
|
||||
"admin_ads_invalid_cost": "❌ Invalid amount. Enter a non-negative number.",
|
||||
"admin_ads_start_param_exists": "❌ A campaign with this start parameter already exists.",
|
||||
"admin_ads_created_success": "✅ Campaign created!\nID: {id}\nSource: {source}\nParam: {start_param}\nCost: {cost} RUB",
|
||||
"admin_ads_back_to_menu_hint": "Done. Back to Ads section:",
|
||||
"admin_ads_overview": "📈 <b>Ads</b>\n💰 Revenue: <b>{revenue} RUB</b>\n💸 Spent: <b>{cost} RUB</b>",
|
||||
"back_to_ads_list_button": "⬅️ Back to list",
|
||||
"admin_ads_card": "📈 <b>Campaign #{id}</b>\nSource: <b>{source}</b>\nstart=<code>{start_param}</code>\nCost: <b>{cost} RUB</b>\nActive: {active}\n\n👥 Starts: <b>{starts}</b>\n🆓 Trials: <b>{trials}</b>\n💳 Payers: <b>{payers}</b>\n💵 Revenue: <b>{revenue} RUB</b>"
|
||||
}
|
||||
|
||||
+49
-1
@@ -5,7 +5,9 @@
|
||||
"menu_activate_trial_button": "🆓 Пробный период",
|
||||
"menu_subscribe_inline": "🚀 Купить",
|
||||
"menu_my_subscription_inline": "🔐 Моя подписка",
|
||||
"no_subscription_options_available": "Выдача подписки не натроена администратором бота",
|
||||
"menu_referral_inline": "🎁 Рефералы",
|
||||
"referral_no_bonuses_configured": "Извините, реферальная программа в данный момент отключена",
|
||||
"menu_apply_promo_button": "🎟 Промокод",
|
||||
"menu_language_settings_inline": "🌐 Язык",
|
||||
"menu_server_status_button": "📊 Статус",
|
||||
@@ -139,7 +141,11 @@
|
||||
"subscription_24h_notification": "👋 Привет, {user_name}!\n\n⏳ Ваша подписка на VPN истекает через 1 день — {end_date}.\n\nПродлите её по кнопке ниже.",
|
||||
"subscription_expired_notification": "👋 Привет, {user_name}!\n\n⛔ Срок вашей подписки на VPN истек ({end_date}).\n\nПродлите её по кнопке ниже.",
|
||||
"subscription_expired_yesterday_notification": "👋 Привет, {user_name}!\n\n⏳ Ваша подписка на VPN истекла сутки назад ({end_date}).\n\nПродлите её по кнопке ниже.",
|
||||
"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": "⬅️ В панель",
|
||||
"admin_promo_unlimited": "♾️ Неограниченно",
|
||||
@@ -370,6 +376,31 @@
|
||||
"admin_sync_not_found_in_db": "\n❌ Не найдено в БД: {count}",
|
||||
"admin_payments_pagination_info": "📊 Показано {shown} из {total} платежей (стр. {current_page}/{total_pages})",
|
||||
"my_subscription_details": "🔐 <b>Моя подписка</b>\n\n⏰ Статус: <b>{status}</b>\n📅 Действует до: <b>{end_date}</b>\n📆 Осталось дней: <b>{days_left}</b>\n\n🔗 Ссылка на конфигурацию:\n<code>{config_link}</code>\n\n📊 Трафик:\nЛимит: <b>{traffic_limit}</b>\nИспользовано: <b>{traffic_used}</b>",
|
||||
"autorenew_enable_button": "🔄 Включить автопродление",
|
||||
"autorenew_disable_button": "🛑 Отключить автопродление",
|
||||
"subscription_autorenew_updated": "Настройки автопродления обновлены.",
|
||||
"payment_methods_manage_button": "💳 Способы оплаты",
|
||||
"payment_methods_title": "💳 <b>Способы оплаты</b>",
|
||||
"payment_method_bind_button": "➕ Добавить способ",
|
||||
"payment_method_delete_button": "🗑 Удалить",
|
||||
"payment_method_view_button": "ℹ️ Детали",
|
||||
"payment_method_none": "У вас пока нет сохранённого способа оплаты.",
|
||||
"payment_method_bound_success": "✅ Способ оплаты добавлен.",
|
||||
"payment_method_deleted_success": "✅ Способ оплаты удалён.",
|
||||
"payment_method_delete_confirm": "Удалить сохранённый способ оплаты?",
|
||||
"payment_method_card_title": "💳 {network} ••••{last4}",
|
||||
"payment_method_generic_title": "💳 {network}",
|
||||
"payment_method_wallet_title": "💼 Кошелёк YooMoney ••••{last4}",
|
||||
"payment_network_card": "Карта",
|
||||
"payment_network_generic": "Способ оплаты",
|
||||
"payment_method_added_at": "Добавлена: {date}",
|
||||
"payment_method_last_tx": "Последняя операция: {date}",
|
||||
"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.",
|
||||
"subscription_not_active": "У вас нет активной подписки.",
|
||||
"error_service_unavailable": "Сервис недоступен. Попробуйте позже.",
|
||||
"error_payment_gateway": "Ошибка платежного сервиса. Попробуйте позже.",
|
||||
@@ -379,5 +410,22 @@
|
||||
"error_creating_payment_record": "Ошибка создания записи платежа. Попробуйте позже.",
|
||||
"error_payment_gateway_link_failed": "Ошибка создания платежной ссылки. Попробуйте позже.",
|
||||
"status_active": "Активна",
|
||||
"status_inactive": "Неактивна"
|
||||
"status_inactive": "Неактивна",
|
||||
"admin_ads_section": "📈 Реклама",
|
||||
"admin_ads_header": "📈 Рекламные кампании:",
|
||||
"admin_ads_empty": "📭 Рекламные кампании отсутствуют. Нажмите \"Создать\" чтобы добавить новую.",
|
||||
"admin_ads_item": "ID: {id}\nИсточник: <b>{source}</b>\nstart={start_param}\nСтоимость: {cost} RUB\nАктивна: {active}\n— Запустили: {starts}\n— Взяли триал: {trials}\n— Оплатили: {payers}\n— Доход: {revenue} RUB",
|
||||
"admin_ads_create_button": "➕ Создать кампанию",
|
||||
"admin_ads_create_source_prompt": "Введите источник (например: AEZA, VK, TG-канал):",
|
||||
"admin_ads_create_start_param_prompt": "Введите параметр старт-ссылки (например: AEZA). Будет использован как start=AEZA",
|
||||
"admin_ads_create_cost_prompt": "Введите сумму затрат на кампанию (в RUB):",
|
||||
"admin_ads_invalid_source": "❌ Неверный источник. Введите до 64 символов.",
|
||||
"admin_ads_invalid_start_param": "❌ Неверный параметр. Допустимы буквы/цифры/подчёркивания/дефисы (2-64).",
|
||||
"admin_ads_invalid_cost": "❌ Неверная сумма. Введите неотрицательное число.",
|
||||
"admin_ads_start_param_exists": "❌ Кампания с таким start-параметром уже существует.",
|
||||
"admin_ads_created_success": "✅ Кампания создана!\nID: {id}\nИсточник: {source}\nПараметр: {start_param}\nЗатраты: {cost} RUB",
|
||||
"admin_ads_back_to_menu_hint": "Готово. Вернуться к разделу рекламы:",
|
||||
"admin_ads_overview": "📈 <b>Реклама</b>\n💰 Пришло: <b>{revenue} RUB</b>\n💸 Потрачено: <b>{cost} RUB</b>",
|
||||
"back_to_ads_list_button": "⬅️ К списку",
|
||||
"admin_ads_card": "📈 <b>Кампания #{id}</b>\nИсточник: <b>{source}</b>\nstart=<code>{start_param}</code>\nСтоимость: <b>{cost} RUB</b>\nАктивна: {active}\n\n👥 Запустили: <b>{starts}</b>\n🆓 Взяли триал: <b>{trials}</b>\n💳 Оплатили: <b>{payers}</b>\n💵 Доход: <b>{revenue} RUB</b>"
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user