Compare commits
61 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 55f386d7e8 | |||
| e6a310dc32 | |||
| ccd8f86e96 | |||
| b6d4373933 | |||
| 266620904d | |||
| 479af5741a | |||
| 79c110ff41 | |||
| 3d78974af7 | |||
| 57c5c679ee | |||
| 2781236011 | |||
| 4a002b7db1 | |||
| 8b2668087b | |||
| 3ec9e71de7 | |||
| da7a9cc3c5 | |||
| b5471b7720 | |||
| 6a4ce3dd38 | |||
| df086b09c7 | |||
| 01132a7bc7 | |||
| 182667ecb8 | |||
| c8f4cca340 | |||
| ac9fcd8d30 | |||
| c08c903e8f | |||
| 69bb399b63 | |||
| 463c5385d6 | |||
| 41dfe39518 | |||
| f44c9b6903 | |||
| 1f35d45dc6 | |||
| ef8f6625bf | |||
| 7101555da0 | |||
| e15b18fb41 | |||
| b80eeea089 | |||
| cb61014d9c | |||
| 5b3353433b | |||
| f1d45343e9 | |||
| b40a812f3a | |||
| ac00434645 | |||
| 964c33c772 | |||
| ec875837a4 | |||
| db0e169a41 | |||
| a6dcf26c20 | |||
| 5081debee7 | |||
| 0ceff44c30 | |||
| 136f29c1eb | |||
| d0eab3f7aa | |||
| d7ad9d7033 | |||
| 1a87d438fe | |||
| aec01ce0d4 | |||
| a33a893d1a | |||
| 37c9b931ca | |||
| 22e7f150b3 | |||
| 688882237f | |||
| c14d7ab0af | |||
| e12cc9f248 | |||
| 6ff0460607 | |||
| 8d5a002996 | |||
| 31bdf8a0ae | |||
| 1364158e6c | |||
| d7931a2afa | |||
| b032c8f354 | |||
| 1306c24fa3 | |||
| 38deb70f81 |
@@ -491,16 +491,11 @@ YOOKASSA_WEBHOOK_PORT=8082
|
||||
YOOKASSA_MIN_AMOUNT_KOPEKS=5000
|
||||
YOOKASSA_MAX_AMOUNT_KOPEKS=1000000
|
||||
|
||||
# Быстрый выбор суммы пополнения через YooKassa
|
||||
YOOKASSA_QUICK_AMOUNT_SELECTION_ENABLED=true
|
||||
|
||||
# Рекуррентные платежи YooKassa (автосохранение карты для автоплатежей)
|
||||
YOOKASSA_RECURRENT_ENABLED=false
|
||||
# true = карта сохраняется обязательно, false = пользователь решает (чекбокс на стороне YooKassa)
|
||||
YOOKASSA_RECURRENT_REQUIRED=true
|
||||
|
||||
# Отключить отображение кнопок выбора суммы пополнения (оставить только ввод вручную)
|
||||
DISABLE_TOPUP_BUTTONS=false
|
||||
# Отключить пополнение баланса через поддержку
|
||||
SUPPORT_TOPUP_ENABLED=true
|
||||
|
||||
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 850 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 1.4 MiB |
Binary file not shown.
|
After Width: | Height: | Size: 2.0 MiB |
Binary file not shown.
|
After Width: | Height: | Size: 28 KiB |
@@ -1,3 +1,3 @@
|
||||
{
|
||||
".": "3.34.0"
|
||||
".": "3.36.0"
|
||||
}
|
||||
|
||||
@@ -1,5 +1,85 @@
|
||||
# Changelog
|
||||
|
||||
## [3.36.0](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/compare/v3.35.0...v3.36.0) (2026-03-20)
|
||||
|
||||
|
||||
### New Features
|
||||
|
||||
* add media attachment support for admin ticket replies ([69bb399](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/69bb399b63d6e1d761cc5518e6272917fc5a6ae7))
|
||||
* add multi-provider recovery, retry_count, amount verification, and indexes ([3d78974](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/3d78974af70b360449d9cf634e09a79821cdc7c0))
|
||||
* add partner → campaign edges to referral network graph ([01132a7](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/01132a7bc77b07eaaaf876c05d639bfed83e5324))
|
||||
* add referral network graph visualization admin API ([c08c903](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/c08c903e8f94f3730872b19de904ce166ba35b98))
|
||||
* add scoped referral network graph with scope selector API ([df086b0](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/df086b09c75a9157cdc558fb610b617a2d49deaf))
|
||||
* multi-select scope for referral network graph API ([6a4ce3d](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/6a4ce3dd38dc3cf2e9db08322093bfd0b84f1e1c))
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* address review findings for guest purchase recovery ([57c5c67](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/57c5c679eef987e9bacee1a9d420ac3c0ff69ff7))
|
||||
* address review findings for multi-provider recovery ([79c110f](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/79c110ff41659ff225164c13690bafc859212d05))
|
||||
* allow repeated auto-assignment of promo groups on each purchase ([4a002b7](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/4a002b7db1bfa8fd1149b0e3dfa469e8912a0e3b))
|
||||
* correct revenue calculations in referral network ([c8f4cca](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/c8f4cca34053713eb2793bbb82e74cbb9a6f893c))
|
||||
* improve referral network query correctness and cleanup ([ac9fcd8](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/ac9fcd8d30dd64fdc7363e689e9ffaf03700976e))
|
||||
* prevent duplicate promo groups during auto-assignment after purchase ([da7a9cc](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/da7a9cc3c5fd771b932a2bfd154400f6f923a4d1))
|
||||
* prevent guest purchases from getting stuck in PENDING/FAILED status ([2781236](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/2781236011942e794949544b9c5422aa8679e5eb))
|
||||
* prevent premature commits in promocode promo group operations ([8b26680](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/8b2668087b4831c08474846b20591b2158d607fc))
|
||||
* propagate exceptions from get_primary_user_promo_group ([3ec9e71](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/3ec9e71de7d8d40e969f9ea738455445d04d983a))
|
||||
* use 'kassa_ai' base model name for guest metadata patch ([182667e](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/182667ecb86f9bbbe87d640865dcbcaadf9e72f6))
|
||||
* use base model name for KassaAI guest metadata patch ([b6d4373](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/b6d43739337cd2e2cfd61ac33398eb6def5b28b6))
|
||||
|
||||
|
||||
### Performance
|
||||
|
||||
* add covering indexes for referral network queries ([b5471b7](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/b5471b7720213c217fc452dc1234a7d3c53447d5))
|
||||
|
||||
## [3.35.0](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/compare/v3.34.1...v3.35.0) (2026-03-18)
|
||||
|
||||
|
||||
### New Features
|
||||
|
||||
* add user_email to admin payments API response ([7101555](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/7101555da0722d1eacd97f40b6b8c8c3a2327a0c))
|
||||
* include manual admin top-ups in sales statistics revenue ([b80eeea](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/b80eeea089568c60c20b1ae165b8dbe887bbe378))
|
||||
* раздельные топики для админских уведомлений ([e15b18f](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/e15b18fb41b180e7dd3d65f2f058667be321fe85))
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* fix Platega and CryptoBot webhook verification ([b40a812](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/b40a812f3aa0596bf6c5105008451dd8a17b103f))
|
||||
* handle None autopay_days_before in autopayment processing ([f1d4534](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/f1d45343e941594d69f71e822ecc9b3a7062f4bf))
|
||||
* remove forced white background from custom email template overrides ([cb61014](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/cb61014d9c5a89e3aeafb191f1b9826ca1cbf338))
|
||||
* undefined currency variable in RioPay payment creation ([5b33534](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/5b3353433bc524e3e51f2ae87d26bd162bd9f97b))
|
||||
|
||||
## [3.34.1](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/compare/v3.34.0...v3.34.1) (2026-03-18)
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* add null check for subscription in execute_change_devices ([5081deb](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/5081debee7625954bd7b82f66e09dd58e08e8822))
|
||||
* correct CryptoBot webhook signature verification and auto-fill topup amount from cart ([d7ad9d7](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/d7ad9d70330b6ef5599f7a9409cdd16657d61b85))
|
||||
* correct RioPay API header case and remove undocumented fields ([1a87d43](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/1a87d438fe127a0b62bd6ee59887212869a3cb17))
|
||||
* disable quick amount buttons in balance topup ([d0eab3f](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/d0eab3f7aacf0249ca244f168c96044347c918e3))
|
||||
* reset device limit to new tariff base on tariff switch ([aec01ce](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/aec01ce0d4a36da5ddd07b56ca4bd5de04735a0b))
|
||||
* sync crypto link from happ.cryptoLink in webhook handlers ([0ceff44](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/0ceff44c30cf11466c4cbe51b520558c51c4af4a))
|
||||
|
||||
|
||||
### Refactoring
|
||||
|
||||
* remove quick amount buttons feature entirely ([136f29c](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/136f29c1eb63778b2b329ed5bf72ab06a4531d0b))
|
||||
|
||||
|
||||
### Documentation
|
||||
|
||||
* add bot preview screenshot to README ([d7931a2](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/d7931a2afaf272aae74453f2bb6d493593895f67))
|
||||
* add cabinet preview screenshot to README ([b032c8f](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/b032c8f35435ddd592df04d6352097580f5e9837))
|
||||
* add icons and list all 14+1 payment providers ([1306c24](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/1306c24fa36fd3e812c5bec551a0aca450ca7d2c))
|
||||
* add Redis to tech stack ([c14d7ab](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/c14d7ab0af2e5dd4f35213b323a97eedcc99af0e))
|
||||
* add Redis to tech stack ([e12cc9f](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/e12cc9f248764104d538db1daaede9ddd8b77b2d))
|
||||
* add WATA partnership block to payments section ([31bdf8a](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/31bdf8a0aeba53fcec40358d0a38b8480130a346))
|
||||
* increase logo size to 800px ([22e7f15](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/22e7f150b30c35b0419e7923e94c5ebc92c4b61a))
|
||||
* redesign README — concise feature showcase, link to docs ([38deb70](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/38deb70f8118d371e36ac92a4417bdb543635fc9))
|
||||
* replace header logo with new artwork ([6888822](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/688882237fe3019bb78d5e1d7ad54faf4cd69c09))
|
||||
* WATA partnership block with logo and table card ([8d5a002](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/8d5a0029964ba52b40e03ebfe8ab6f4146d3aca9))
|
||||
|
||||
## [3.34.0](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/compare/v3.33.0...v3.34.0) (2026-03-18)
|
||||
|
||||
|
||||
|
||||
+1
-1
@@ -19,7 +19,7 @@ RUN --mount=type=cache,target=/root/.cache/uv \
|
||||
|
||||
FROM python:3.13-slim
|
||||
|
||||
ARG VERSION="v3.34.0" # x-release-please-version
|
||||
ARG VERSION="v3.36.0" # x-release-please-version
|
||||
ARG BUILD_DATE
|
||||
ARG VCS_REF
|
||||
|
||||
|
||||
@@ -20,6 +20,7 @@ from .admin_pinned_messages import router as admin_pinned_messages_router
|
||||
from .admin_policies import router as admin_policies_router
|
||||
from .admin_promo_offers import router as admin_promo_offers_router
|
||||
from .admin_promocodes import promo_groups_router as admin_promo_groups_router, router as admin_promocodes_router
|
||||
from .admin_referral_network import router as admin_referral_network_router
|
||||
from .admin_remnawave import router as admin_remnawave_router
|
||||
from .admin_roles import router as admin_roles_router
|
||||
from .admin_sales_stats import router as admin_sales_stats_router
|
||||
@@ -99,6 +100,7 @@ router.include_router(admin_wheel_router)
|
||||
router.include_router(admin_tariffs_router)
|
||||
router.include_router(admin_servers_router)
|
||||
router.include_router(admin_stats_router)
|
||||
router.include_router(admin_referral_network_router)
|
||||
router.include_router(admin_sales_stats_router)
|
||||
router.include_router(admin_ban_system_router)
|
||||
router.include_router(admin_broadcasts_router)
|
||||
|
||||
@@ -670,8 +670,8 @@ async def preview_template(
|
||||
language = data.language if data.language in AVAILABLE_LANGUAGES else 'ru'
|
||||
|
||||
if data.body_html:
|
||||
# Preview custom content wrapped in base template
|
||||
rendered_html = templates_instance._get_base_template(data.body_html, language)
|
||||
# Preview custom content — auto-detects styled vs simple HTML
|
||||
rendered_html = templates_instance._wrap_override_template(data.body_html, language)
|
||||
subject = data.subject or notification_type
|
||||
else:
|
||||
# Preview default template
|
||||
|
||||
@@ -62,6 +62,7 @@ class PendingPaymentResponse(BaseModel):
|
||||
user_id: int | None = None
|
||||
user_telegram_id: int | None = None
|
||||
user_username: str | None = None
|
||||
user_email: str | None = None
|
||||
|
||||
class Config:
|
||||
from_attributes = True
|
||||
@@ -285,6 +286,7 @@ def _record_to_response(record: PendingPayment) -> PendingPaymentResponse:
|
||||
user_id=record.user.id if record.user else None,
|
||||
user_telegram_id=record.user.telegram_id if record.user else None,
|
||||
user_username=record.user.username if record.user else None,
|
||||
user_email=record.user.email if record.user else None,
|
||||
)
|
||||
|
||||
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -10,6 +10,7 @@ from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.database.crud.transaction import REAL_PAYMENT_METHODS
|
||||
from app.database.models import (
|
||||
PaymentMethod,
|
||||
Subscription,
|
||||
SubscriptionConversion,
|
||||
SubscriptionStatus,
|
||||
@@ -87,6 +88,7 @@ class SalesSummary(BaseModel):
|
||||
"""Summary stats for the top cards."""
|
||||
|
||||
total_revenue_kopeks: int
|
||||
manual_topup_kopeks: int
|
||||
active_subscriptions: int
|
||||
active_trials: int
|
||||
new_trials: int
|
||||
@@ -124,6 +126,20 @@ async def get_sales_summary(
|
||||
)
|
||||
total_revenue = revenue_result.scalar() or 0
|
||||
|
||||
# Manual top-ups by admins
|
||||
manual_topup_result = await db.execute(
|
||||
select(func.coalesce(func.sum(Transaction.amount_kopeks), 0)).where(
|
||||
and_(
|
||||
Transaction.type == TransactionType.DEPOSIT.value,
|
||||
Transaction.is_completed == True,
|
||||
Transaction.payment_method == PaymentMethod.MANUAL.value,
|
||||
Transaction.created_at >= period_start,
|
||||
Transaction.created_at <= period_end,
|
||||
)
|
||||
)
|
||||
)
|
||||
manual_topup = manual_topup_result.scalar() or 0
|
||||
|
||||
# Consolidated subscription counts: active paid, active trial, new trials in period
|
||||
sub_counts_result = await db.execute(
|
||||
select(
|
||||
@@ -243,7 +259,8 @@ async def get_sales_summary(
|
||||
addon_revenue = abs(addon_revenue_result.scalar() or 0)
|
||||
|
||||
return SalesSummary(
|
||||
total_revenue_kopeks=total_revenue,
|
||||
total_revenue_kopeks=total_revenue + manual_topup,
|
||||
manual_topup_kopeks=manual_topup,
|
||||
active_subscriptions=active_subs,
|
||||
active_trials=active_trials,
|
||||
new_trials=new_trials,
|
||||
@@ -1060,10 +1077,11 @@ async def get_deposits_stats(
|
||||
try:
|
||||
period_start, period_end = _parse_period(days, start_date, end_date)
|
||||
|
||||
methods_with_manual = [*REAL_PAYMENT_METHODS, PaymentMethod.MANUAL.value]
|
||||
base_filter = and_(
|
||||
Transaction.type == TransactionType.DEPOSIT.value,
|
||||
Transaction.is_completed == True,
|
||||
Transaction.payment_method.in_(REAL_PAYMENT_METHODS),
|
||||
Transaction.payment_method.in_(methods_with_manual),
|
||||
Transaction.created_at >= period_start,
|
||||
Transaction.created_at <= period_end,
|
||||
)
|
||||
@@ -1114,7 +1132,7 @@ async def get_deposits_stats(
|
||||
]
|
||||
|
||||
# Daily deposits grouped by payment method
|
||||
# base_filter already excludes NULLs via .in_(REAL_PAYMENT_METHODS), no coalesce needed
|
||||
# base_filter already excludes NULLs via .in_(methods_with_manual), no coalesce needed
|
||||
daily_by_method_query = await db.execute(
|
||||
select(
|
||||
func.date(Transaction.created_at).label('date'),
|
||||
|
||||
@@ -5,7 +5,7 @@ from datetime import UTC, datetime
|
||||
|
||||
import structlog
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query, status
|
||||
from pydantic import BaseModel, Field
|
||||
from pydantic import BaseModel, Field, model_validator
|
||||
from sqlalchemy import desc, func, select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from sqlalchemy.orm import selectinload
|
||||
@@ -90,6 +90,19 @@ class AdminReplyRequest(BaseModel):
|
||||
"""Admin reply to ticket."""
|
||||
|
||||
message: str = Field(..., min_length=1, max_length=4000, description='Reply message')
|
||||
media_type: str | None = Field(None, description='Media type: photo, video, or document')
|
||||
media_file_id: str | None = Field(None, max_length=255, description='Telegram file_id from media upload')
|
||||
media_caption: str | None = Field(None, max_length=1000, description='Caption for media')
|
||||
|
||||
@model_validator(mode='after')
|
||||
def validate_media_fields(self) -> 'AdminReplyRequest':
|
||||
if self.media_file_id and not self.media_type:
|
||||
raise ValueError('media_type is required when media_file_id is provided')
|
||||
if self.media_type and not self.media_file_id:
|
||||
raise ValueError('media_file_id is required when media_type is provided')
|
||||
if self.media_type and self.media_type not in {'photo', 'video', 'document'}:
|
||||
raise ValueError('media_type must be one of: photo, video, document')
|
||||
return self
|
||||
|
||||
|
||||
class AdminStatusUpdateRequest(BaseModel):
|
||||
@@ -443,11 +456,16 @@ async def reply_to_ticket(
|
||||
)
|
||||
|
||||
# Create admin message
|
||||
has_media = bool(request.media_file_id)
|
||||
message = TicketMessage(
|
||||
ticket_id=ticket.id,
|
||||
user_id=ticket.user_id,
|
||||
message_text=request.message,
|
||||
is_from_admin=True,
|
||||
has_media=has_media,
|
||||
media_type=request.media_type if has_media else None,
|
||||
media_file_id=request.media_file_id if has_media else None,
|
||||
media_caption=request.media_caption if has_media else None,
|
||||
created_at=datetime.now(UTC),
|
||||
)
|
||||
db.add(message)
|
||||
|
||||
@@ -4107,7 +4107,7 @@ async def switch_tariff(
|
||||
# Update subscription
|
||||
old_tariff_name = current_tariff.name if current_tariff else 'Unknown'
|
||||
|
||||
# Preserve extra purchased devices above the old tariff's base limit
|
||||
# Reset device limit to new tariff base (extra purchased devices are not carried over)
|
||||
from app.database.crud.subscription import calc_device_limit_on_tariff_switch
|
||||
|
||||
# Re-load subscription to avoid MissingGreenlet from expired lazy relationship
|
||||
|
||||
@@ -198,7 +198,7 @@ async def get_rendered_override(
|
||||
for key, value in context.items():
|
||||
body_html = body_html.replace(f'{{{key}}}', html.escape(str(value)))
|
||||
|
||||
rendered = templates._get_base_template(body_html, language)
|
||||
rendered = templates._wrap_override_template(body_html, language)
|
||||
subject = override['subject']
|
||||
|
||||
# Also substitute in subject
|
||||
|
||||
@@ -74,6 +74,39 @@ class EmailNotificationTemplates:
|
||||
|
||||
return template_func(language, context)
|
||||
|
||||
def _wrap_override_template(self, content: str, language: str = 'ru') -> str:
|
||||
"""Wrap override template content appropriately based on its structure.
|
||||
|
||||
Three-tier detection:
|
||||
1. Full HTML document (<!DOCTYPE or <html>) — return as-is, no wrapping
|
||||
2. Styled content (has <style> tag or background CSS) — minimal HTML wrapper
|
||||
without forced colors, headers, or footers
|
||||
3. Simple HTML fragment — wrap with base template (header, footer, white bg)
|
||||
for backward compatibility
|
||||
"""
|
||||
content_stripped = content.strip()
|
||||
content_lower = content_stripped.lower()
|
||||
|
||||
# Tier 1: Full HTML document — return as-is
|
||||
if content_lower.startswith('<!doctype') or content_lower.startswith('<html'):
|
||||
return content_stripped
|
||||
|
||||
# Tier 2: Styled content — minimal wrapper without forced styling
|
||||
if '<style' in content_lower or 'background' in content_lower:
|
||||
return f"""<!DOCTYPE html>
|
||||
<html>
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
</head>
|
||||
<body style="margin: 0; padding: 0;">
|
||||
{content}
|
||||
</body>
|
||||
</html>"""
|
||||
|
||||
# Tier 3: Simple HTML fragment — use base template for structure
|
||||
return self._get_base_template(content, language)
|
||||
|
||||
def _get_base_template(self, content: str, language: str = 'ru') -> str:
|
||||
"""Wrap content in base HTML template."""
|
||||
footer_texts = {
|
||||
|
||||
+11
-6
@@ -56,6 +56,17 @@ class Settings(BaseSettings):
|
||||
ADMIN_NOTIFICATIONS_TICKET_TOPIC_ID: int | None = None
|
||||
ADMIN_NOTIFICATIONS_NALOG_TOPIC_ID: int | None = None
|
||||
|
||||
# Раздельные топики для уведомлений (если не задано — fallback на ADMIN_NOTIFICATIONS_TOPIC_ID)
|
||||
ADMIN_NOTIFICATIONS_PURCHASES_TOPIC_ID: int | None = None # Покупки подписок
|
||||
ADMIN_NOTIFICATIONS_RENEWALS_TOPIC_ID: int | None = None # Продления
|
||||
ADMIN_NOTIFICATIONS_TRIALS_TOPIC_ID: int | None = None # Триалы
|
||||
ADMIN_NOTIFICATIONS_BALANCE_TOPIC_ID: int | None = None # Пополнение баланса
|
||||
ADMIN_NOTIFICATIONS_ADDONS_TOPIC_ID: int | None = None # Докупка трафика/устройств/серверов
|
||||
ADMIN_NOTIFICATIONS_INFRASTRUCTURE_TOPIC_ID: int | None = None # Ноды, техработы, статус панели
|
||||
ADMIN_NOTIFICATIONS_ERRORS_TOPIC_ID: int | None = None # Ошибки бота
|
||||
ADMIN_NOTIFICATIONS_PROMO_TOPIC_ID: int | None = None # Промокоды, кампании, промогруппы
|
||||
ADMIN_NOTIFICATIONS_PARTNERS_TOPIC_ID: int | None = None # Партнёрки, выводы, админ-действия
|
||||
|
||||
# Настройки очереди чеков NaloGO
|
||||
NALOGO_QUEUE_CHECK_INTERVAL: int = 300 # Интервал проверки очереди (секунды)
|
||||
NALOGO_QUEUE_RECEIPT_DELAY: int = 3 # Задержка между отправкой чеков (секунды)
|
||||
@@ -353,10 +364,8 @@ class Settings(BaseSettings):
|
||||
YOOKASSA_TRUSTED_PROXY_NETWORKS: str = ''
|
||||
YOOKASSA_MIN_AMOUNT_KOPEKS: int = 5000
|
||||
YOOKASSA_MAX_AMOUNT_KOPEKS: int = 1000000
|
||||
YOOKASSA_QUICK_AMOUNT_SELECTION_ENABLED: bool = False
|
||||
YOOKASSA_RECURRENT_ENABLED: bool = False
|
||||
YOOKASSA_RECURRENT_REQUIRED: bool = False
|
||||
DISABLE_TOPUP_BUTTONS: bool = False
|
||||
SUPPORT_TOPUP_ENABLED: bool = True
|
||||
PAYMENT_VERIFICATION_AUTO_CHECK_ENABLED: bool = False
|
||||
PAYMENT_VERIFICATION_AUTO_CHECK_INTERVAL_MINUTES: int = 10
|
||||
@@ -1254,10 +1263,6 @@ class Settings(BaseSettings):
|
||||
|
||||
return bool(value)
|
||||
|
||||
def is_quick_amount_buttons_enabled(self) -> bool:
|
||||
"""Показывать ли кнопки быстрого выбора суммы пополнения."""
|
||||
return self.YOOKASSA_QUICK_AMOUNT_SELECTION_ENABLED and not self.DISABLE_TOPUP_BUTTONS
|
||||
|
||||
def get_available_languages(self) -> list[str]:
|
||||
defaults = ['ru', 'en', 'ua', 'zh', 'fa']
|
||||
|
||||
|
||||
@@ -40,23 +40,18 @@ def calc_device_limit_on_tariff_switch(
|
||||
new_tariff_device_limit: int | None,
|
||||
max_device_limit: int | None = None,
|
||||
) -> int:
|
||||
"""Calculate device_limit preserving extra purchased devices when switching tariffs.
|
||||
"""Calculate device_limit when switching tariffs.
|
||||
|
||||
Extra devices = current_device_limit - old_tariff_device_limit (clamped to 0).
|
||||
Result = new_tariff_device_limit + extra_devices, capped at max_device_limit.
|
||||
Resets to new tariff base device limit — previously purchased
|
||||
extra devices are NOT carried over. Capped at max_device_limit.
|
||||
"""
|
||||
old_base = old_tariff_device_limit if old_tariff_device_limit is not None else 0
|
||||
current = current_device_limit if current_device_limit is not None else old_base
|
||||
extra = max(0, current - old_base)
|
||||
|
||||
new_base = new_tariff_device_limit if new_tariff_device_limit is not None else 1
|
||||
total = new_base + extra
|
||||
|
||||
effective_max = max_device_limit or (settings.MAX_DEVICES_LIMIT if settings.MAX_DEVICES_LIMIT > 0 else None)
|
||||
if effective_max and total > effective_max:
|
||||
total = effective_max
|
||||
if effective_max and new_base > effective_max:
|
||||
new_base = effective_max
|
||||
|
||||
return total
|
||||
return new_base
|
||||
|
||||
|
||||
def is_active_paid_subscription(subscription: Subscription | None) -> bool:
|
||||
|
||||
@@ -108,7 +108,7 @@ async def create_transaction(
|
||||
|
||||
await maybe_assign_promo_group_by_total_spent(db, user_id)
|
||||
except Exception as exc:
|
||||
logger.debug('Не удалось проверить автовыдачу промогруппы для пользователя', user_id=user_id, exc=exc)
|
||||
logger.warning('Не удалось проверить автовыдачу промогруппы для пользователя', user_id=user_id, exc=exc)
|
||||
if type == TransactionType.SUBSCRIPTION_PAYMENT and is_completed:
|
||||
try:
|
||||
from app.services.referral_contest_service import referral_contest_service
|
||||
@@ -168,7 +168,7 @@ async def emit_transaction_side_effects(
|
||||
|
||||
await maybe_assign_promo_group_by_total_spent(db, user_id)
|
||||
except Exception as exc:
|
||||
logger.debug('Не удалось проверить автовыдачу промогруппы для пользователя', user_id=user_id, exc=exc)
|
||||
logger.warning('Не удалось проверить автовыдачу промогруппы для пользователя', user_id=user_id, exc=exc)
|
||||
|
||||
if type == TransactionType.SUBSCRIPTION_PAYMENT and is_completed:
|
||||
try:
|
||||
@@ -253,7 +253,7 @@ async def complete_transaction(db: AsyncSession, transaction: Transaction) -> Tr
|
||||
|
||||
await maybe_assign_promo_group_by_total_spent(db, transaction.user_id)
|
||||
except Exception as exc:
|
||||
logger.debug(
|
||||
logger.warning(
|
||||
'Не удалось проверить автовыдачу промогруппы для пользователя', user_id=transaction.user_id, exc=exc
|
||||
)
|
||||
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
from datetime import UTC, datetime
|
||||
|
||||
import structlog
|
||||
from sqlalchemy import and_, desc, select
|
||||
from sqlalchemy import and_, desc, func, select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from sqlalchemy.orm import selectinload
|
||||
|
||||
@@ -24,7 +24,7 @@ async def _sync_user_primary_promo_group(
|
||||
select(UserPromoGroup.promo_group_id)
|
||||
.join(PromoGroup, UserPromoGroup.promo_group_id == PromoGroup.id)
|
||||
.where(UserPromoGroup.user_id == user_id)
|
||||
.order_by(desc(PromoGroup.priority), PromoGroup.id)
|
||||
.order_by(desc(PromoGroup.priority), desc(PromoGroup.id))
|
||||
)
|
||||
|
||||
first = result.first()
|
||||
@@ -53,7 +53,12 @@ async def sync_user_primary_promo_group(
|
||||
|
||||
|
||||
async def add_user_to_promo_group(
|
||||
db: AsyncSession, user_id: int, promo_group_id: int, assigned_by: str = 'admin'
|
||||
db: AsyncSession,
|
||||
user_id: int,
|
||||
promo_group_id: int,
|
||||
assigned_by: str = 'admin',
|
||||
*,
|
||||
commit: bool = True,
|
||||
) -> UserPromoGroup | None:
|
||||
"""
|
||||
Добавляет пользователю промогруппу.
|
||||
@@ -63,6 +68,7 @@ async def add_user_to_promo_group(
|
||||
user_id: ID пользователя
|
||||
promo_group_id: ID промогруппы
|
||||
assigned_by: Кто назначил ('admin', 'system', 'auto', 'promocode')
|
||||
commit: Коммитить транзакцию (False для батчевых операций)
|
||||
|
||||
Returns:
|
||||
UserPromoGroup или None если уже существует
|
||||
@@ -85,8 +91,9 @@ async def add_user_to_promo_group(
|
||||
|
||||
await _sync_user_primary_promo_group(db, user_id)
|
||||
|
||||
await db.commit()
|
||||
await db.refresh(user_promo_group)
|
||||
if commit:
|
||||
await db.commit()
|
||||
await db.refresh(user_promo_group)
|
||||
|
||||
logger.info(
|
||||
'Пользователю добавлена промогруппа',
|
||||
@@ -98,11 +105,19 @@ async def add_user_to_promo_group(
|
||||
|
||||
except Exception as error:
|
||||
logger.error('Ошибка добавления промогруппы пользователю', error=error)
|
||||
await db.rollback()
|
||||
return None
|
||||
if commit:
|
||||
await db.rollback()
|
||||
return None
|
||||
raise
|
||||
|
||||
|
||||
async def remove_user_from_promo_group(db: AsyncSession, user_id: int, promo_group_id: int) -> bool:
|
||||
async def remove_user_from_promo_group(
|
||||
db: AsyncSession,
|
||||
user_id: int,
|
||||
promo_group_id: int,
|
||||
*,
|
||||
commit: bool = True,
|
||||
) -> bool:
|
||||
"""
|
||||
Удаляет промогруппу у пользователя.
|
||||
|
||||
@@ -110,6 +125,7 @@ async def remove_user_from_promo_group(db: AsyncSession, user_id: int, promo_gro
|
||||
db: Сессия БД
|
||||
user_id: ID пользователя
|
||||
promo_group_id: ID промогруппы
|
||||
commit: Коммитить транзакцию (False для батчевых операций)
|
||||
|
||||
Returns:
|
||||
True если удалено, False если связи не было
|
||||
@@ -133,15 +149,18 @@ async def remove_user_from_promo_group(db: AsyncSession, user_id: int, promo_gro
|
||||
|
||||
await _sync_user_primary_promo_group(db, user_id)
|
||||
|
||||
await db.commit()
|
||||
if commit:
|
||||
await db.commit()
|
||||
|
||||
logger.info('У пользователя удалена промогруппа', user_id=user_id, promo_group_id=promo_group_id)
|
||||
return True
|
||||
|
||||
except Exception as error:
|
||||
logger.error('Ошибка удаления промогруппы у пользователя', error=error)
|
||||
await db.rollback()
|
||||
return False
|
||||
if commit:
|
||||
await db.rollback()
|
||||
return False
|
||||
raise
|
||||
|
||||
|
||||
async def get_user_promo_groups(db: AsyncSession, user_id: int) -> list[UserPromoGroup]:
|
||||
@@ -155,19 +174,14 @@ async def get_user_promo_groups(db: AsyncSession, user_id: int) -> list[UserProm
|
||||
Returns:
|
||||
Список UserPromoGroup с загруженными PromoGroup, отсортированный по приоритету DESC
|
||||
"""
|
||||
try:
|
||||
result = await db.execute(
|
||||
select(UserPromoGroup)
|
||||
.options(selectinload(UserPromoGroup.promo_group))
|
||||
.where(UserPromoGroup.user_id == user_id)
|
||||
.join(PromoGroup, UserPromoGroup.promo_group_id == PromoGroup.id)
|
||||
.order_by(desc(PromoGroup.priority), PromoGroup.id)
|
||||
)
|
||||
return list(result.scalars().all())
|
||||
|
||||
except Exception as error:
|
||||
logger.error('Ошибка получения промогрупп пользователя', user_id=user_id, error=error)
|
||||
return []
|
||||
result = await db.execute(
|
||||
select(UserPromoGroup)
|
||||
.options(selectinload(UserPromoGroup.promo_group))
|
||||
.where(UserPromoGroup.user_id == user_id)
|
||||
.join(PromoGroup, UserPromoGroup.promo_group_id == PromoGroup.id)
|
||||
.order_by(desc(PromoGroup.priority), desc(PromoGroup.id))
|
||||
)
|
||||
return list(result.scalars().all())
|
||||
|
||||
|
||||
async def get_primary_user_promo_group(db: AsyncSession, user_id: int) -> PromoGroup | None:
|
||||
@@ -181,19 +195,14 @@ async def get_primary_user_promo_group(db: AsyncSession, user_id: int) -> PromoG
|
||||
Returns:
|
||||
PromoGroup с максимальным приоритетом или None
|
||||
"""
|
||||
try:
|
||||
user_promo_groups = await get_user_promo_groups(db, user_id)
|
||||
user_promo_groups = await get_user_promo_groups(db, user_id)
|
||||
|
||||
if not user_promo_groups:
|
||||
return None
|
||||
|
||||
# Первая в списке имеет максимальный приоритет (список уже отсортирован)
|
||||
return user_promo_groups[0].promo_group or None
|
||||
|
||||
except Exception as error:
|
||||
logger.error('Ошибка получения primary промогруппы пользователя', user_id=user_id, error=error)
|
||||
if not user_promo_groups:
|
||||
return None
|
||||
|
||||
# Первая в списке имеет максимальный приоритет (список уже отсортирован)
|
||||
return user_promo_groups[0].promo_group or None
|
||||
|
||||
|
||||
async def has_user_promo_group(db: AsyncSession, user_id: int, promo_group_id: int) -> bool:
|
||||
"""
|
||||
@@ -207,17 +216,12 @@ async def has_user_promo_group(db: AsyncSession, user_id: int, promo_group_id: i
|
||||
Returns:
|
||||
True если пользователь уже имеет эту промогруппу
|
||||
"""
|
||||
try:
|
||||
result = await db.execute(
|
||||
select(UserPromoGroup).where(
|
||||
and_(UserPromoGroup.user_id == user_id, UserPromoGroup.promo_group_id == promo_group_id)
|
||||
)
|
||||
result = await db.execute(
|
||||
select(UserPromoGroup).where(
|
||||
and_(UserPromoGroup.user_id == user_id, UserPromoGroup.promo_group_id == promo_group_id)
|
||||
)
|
||||
return result.scalar_one_or_none() is not None
|
||||
|
||||
except Exception as error:
|
||||
logger.error('Ошибка проверки промогруппы пользователя', error=error)
|
||||
return False
|
||||
)
|
||||
return result.scalar_one_or_none() is not None
|
||||
|
||||
|
||||
async def count_user_promo_groups(db: AsyncSession, user_id: int) -> int:
|
||||
@@ -232,8 +236,10 @@ async def count_user_promo_groups(db: AsyncSession, user_id: int) -> int:
|
||||
Количество промогрупп
|
||||
"""
|
||||
try:
|
||||
result = await db.execute(select(UserPromoGroup).where(UserPromoGroup.user_id == user_id))
|
||||
return len(list(result.scalars().all()))
|
||||
result = await db.execute(
|
||||
select(func.count()).select_from(UserPromoGroup).where(UserPromoGroup.user_id == user_id)
|
||||
)
|
||||
return result.scalar_one()
|
||||
|
||||
except Exception as error:
|
||||
logger.error('Ошибка подсчета промогрупп пользователя', error=error)
|
||||
@@ -257,15 +263,18 @@ async def replace_user_promo_groups(
|
||||
"""
|
||||
try:
|
||||
# Удаляем все текущие промогруппы
|
||||
await db.execute(select(UserPromoGroup).where(UserPromoGroup.user_id == user_id))
|
||||
result = await db.execute(select(UserPromoGroup).where(UserPromoGroup.user_id == user_id))
|
||||
for upg in result.scalars().all():
|
||||
await db.delete(upg)
|
||||
await db.flush()
|
||||
|
||||
# Добавляем новые
|
||||
for promo_group_id in promo_group_ids:
|
||||
user_promo_group = UserPromoGroup(user_id=user_id, promo_group_id=promo_group_id, assigned_by=assigned_by)
|
||||
db.add(user_promo_group)
|
||||
await db.flush()
|
||||
|
||||
await _sync_user_primary_promo_group(db, user_id)
|
||||
|
||||
await db.commit()
|
||||
logger.info('Промогруппы пользователя заменены на', user_id=user_id, promo_group_ids=promo_group_ids)
|
||||
|
||||
@@ -1574,6 +1574,7 @@ class Transaction(Base):
|
||||
Index('ix_transactions_type_created_completed', 'type', 'created_at', 'is_completed'),
|
||||
Index('ix_transactions_user_created', 'user_id', 'created_at'),
|
||||
Index('ix_transactions_type_method_created', 'type', 'payment_method', 'created_at'),
|
||||
Index('ix_transactions_user_type_completed_amount', 'user_id', 'type', 'is_completed', 'amount_kopeks'),
|
||||
)
|
||||
|
||||
id = Column(Integer, primary_key=True, index=True)
|
||||
@@ -2490,7 +2491,10 @@ class AdvertisingCampaign(Base):
|
||||
|
||||
class AdvertisingCampaignRegistration(Base):
|
||||
__tablename__ = 'advertising_campaign_registrations'
|
||||
__table_args__ = (UniqueConstraint('campaign_id', 'user_id', name='uq_campaign_user'),)
|
||||
__table_args__ = (
|
||||
UniqueConstraint('campaign_id', 'user_id', name='uq_campaign_user'),
|
||||
Index('ix_campaign_reg_user_created', 'user_id', 'created_at'),
|
||||
)
|
||||
|
||||
id = Column(Integer, primary_key=True, index=True)
|
||||
campaign_id = Column(Integer, ForeignKey('advertising_campaigns.id', ondelete='CASCADE'), nullable=False)
|
||||
@@ -3283,6 +3287,7 @@ class GuestPurchase(Base):
|
||||
cabinet_password = Column(Text, nullable=True)
|
||||
auto_login_token = Column(Text, nullable=True)
|
||||
recipient_warning = Column(String(50), nullable=True)
|
||||
retry_count = Column(Integer, nullable=False, default=0, server_default='0')
|
||||
|
||||
landing = relationship('LandingPage', back_populates='guest_purchases', lazy='selectin')
|
||||
tariff = relationship('Tariff', lazy='selectin')
|
||||
|
||||
Vendored
+35
-11
@@ -1,5 +1,6 @@
|
||||
import hashlib
|
||||
import hmac
|
||||
import json
|
||||
from typing import Any
|
||||
|
||||
import aiohttp
|
||||
@@ -15,7 +16,6 @@ class CryptoBotService:
|
||||
def __init__(self):
|
||||
self.api_token = settings.CRYPTOBOT_API_TOKEN
|
||||
self.base_url = settings.get_cryptobot_base_url()
|
||||
self.webhook_secret = settings.CRYPTOBOT_WEBHOOK_SECRET
|
||||
|
||||
async def _make_request(
|
||||
self,
|
||||
@@ -122,22 +122,46 @@ class CryptoBotService:
|
||||
return await self._make_request('GET', 'getExchangeRates')
|
||||
|
||||
def verify_webhook_signature(self, body: str, signature: str) -> bool:
|
||||
if not self.webhook_secret:
|
||||
logger.warning('CryptoBot webhook secret не настроен')
|
||||
# По документации CryptoBot, ключ ВСЕГДА SHA256 от API токена
|
||||
token = self.api_token
|
||||
if not token:
|
||||
logger.warning('CryptoBot API token не настроен, пропуск проверки подписи')
|
||||
return True
|
||||
|
||||
try:
|
||||
secret_hash = hashlib.sha256(self.webhook_secret.encode()).digest()
|
||||
expected_signature = hmac.new(secret_hash, body.encode(), hashlib.sha256).hexdigest()
|
||||
secret_hash = hashlib.sha256(token.encode()).digest()
|
||||
|
||||
is_valid = hmac.compare_digest(signature, expected_signature)
|
||||
# 1. Raw body — CryptoBot шлёт compact JSON
|
||||
expected = hmac.new(secret_hash, body.encode('utf-8'), hashlib.sha256).hexdigest()
|
||||
if hmac.compare_digest(signature, expected):
|
||||
logger.info('CryptoBot webhook подпись валидна (raw body)')
|
||||
return True
|
||||
|
||||
if is_valid:
|
||||
logger.info('✅ CryptoBot webhook подпись валидна')
|
||||
else:
|
||||
logger.error('❌ Неверная подпись CryptoBot webhook')
|
||||
# 2. Fallback: re-serialize compact JSON
|
||||
parsed = json.loads(body)
|
||||
check_string = json.dumps(parsed, separators=(',', ':'), ensure_ascii=False)
|
||||
expected_reserialized = hmac.new(secret_hash, check_string.encode('utf-8'), hashlib.sha256).hexdigest()
|
||||
if hmac.compare_digest(signature, expected_reserialized):
|
||||
logger.info('CryptoBot webhook подпись валидна (re-serialized)')
|
||||
return True
|
||||
|
||||
return is_valid
|
||||
# 3. Fallback: ensure_ascii=True
|
||||
check_string_ascii = json.dumps(parsed, separators=(',', ':'), ensure_ascii=True)
|
||||
expected_ascii = hmac.new(secret_hash, check_string_ascii.encode('utf-8'), hashlib.sha256).hexdigest()
|
||||
if hmac.compare_digest(signature, expected_ascii):
|
||||
logger.info('CryptoBot webhook подпись валидна (ascii-escaped)')
|
||||
return True
|
||||
|
||||
logger.error(
|
||||
'Неверная подпись CryptoBot webhook',
|
||||
received_signature=signature,
|
||||
expected_raw=expected,
|
||||
expected_reserialized=expected_reserialized,
|
||||
body_length=len(body),
|
||||
token_length=len(token),
|
||||
token_prefix=token[:4] + '...',
|
||||
)
|
||||
return False
|
||||
|
||||
except Exception as e:
|
||||
logger.error('Ошибка проверки подписи CryptoBot webhook', error=e)
|
||||
|
||||
Vendored
+4
-1
@@ -377,7 +377,10 @@ class WebhookServer:
|
||||
signature = request.headers.get('Crypto-Pay-API-Signature')
|
||||
logger.info('CryptoBot Signature', signature=signature)
|
||||
|
||||
if signature and settings.CRYPTOBOT_WEBHOOK_SECRET:
|
||||
if settings.CRYPTOBOT_API_TOKEN:
|
||||
if not signature:
|
||||
logger.error('CryptoBot webhook без подписи')
|
||||
return web.json_response({'status': 'error', 'reason': 'missing_signature'}, status=401)
|
||||
from app.external.cryptobot import CryptoBotService
|
||||
|
||||
cryptobot_service = CryptoBotService()
|
||||
|
||||
@@ -129,9 +129,9 @@ async def process_cloudpayments_payment_amount(
|
||||
state: FSMContext,
|
||||
):
|
||||
"""
|
||||
Process payment amount directly (called from quick_amount handlers).
|
||||
Process payment amount directly.
|
||||
|
||||
Similar to process_heleket_payment_amount and other payment handlers.
|
||||
Similar to other payment amount handlers.
|
||||
"""
|
||||
texts = get_texts(db_user.language)
|
||||
|
||||
@@ -197,7 +197,7 @@ async def start_cloudpayments_payment(
|
||||
"""
|
||||
Start CloudPayments payment flow.
|
||||
|
||||
Shows amount input prompt or quick amount buttons.
|
||||
Shows amount input prompt.
|
||||
"""
|
||||
texts = get_texts(db_user.language)
|
||||
|
||||
@@ -375,123 +375,3 @@ async def process_cloudpayments_amount(
|
||||
)
|
||||
|
||||
logger.info('CloudPayments payment created: user amount=₽', telegram_id=db_user.telegram_id, amount_rub=amount_rub)
|
||||
|
||||
|
||||
@error_handler
|
||||
async def handle_cloudpayments_quick_amount(
|
||||
callback: types.CallbackQuery,
|
||||
db_user: User,
|
||||
db: AsyncSession,
|
||||
state: FSMContext,
|
||||
):
|
||||
"""
|
||||
Handle quick amount selection for CloudPayments.
|
||||
|
||||
Called when user clicks a predefined amount button.
|
||||
"""
|
||||
texts = get_texts(db_user.language)
|
||||
|
||||
if not settings.is_cloudpayments_enabled():
|
||||
await callback.answer(
|
||||
texts.t('CLOUDPAYMENTS_NOT_AVAILABLE', 'CloudPayments временно недоступен'),
|
||||
show_alert=True,
|
||||
)
|
||||
return
|
||||
|
||||
# Extract amount from callback data: topup_amount|cloudpayments|{amount_kopeks}
|
||||
try:
|
||||
parts = callback.data.split('|')
|
||||
if len(parts) >= 3:
|
||||
amount_kopeks = int(parts[2])
|
||||
else:
|
||||
await callback.answer('Invalid callback data', show_alert=True)
|
||||
return
|
||||
except (ValueError, IndexError):
|
||||
await callback.answer('Invalid amount', show_alert=True)
|
||||
return
|
||||
|
||||
amount_rub = amount_kopeks / 100
|
||||
|
||||
# Validate amount
|
||||
if amount_kopeks < settings.CLOUDPAYMENTS_MIN_AMOUNT_KOPEKS:
|
||||
await callback.answer(
|
||||
texts.t('AMOUNT_TOO_LOW_SHORT', 'Сумма слишком мала'),
|
||||
show_alert=True,
|
||||
)
|
||||
return
|
||||
|
||||
if amount_kopeks > settings.CLOUDPAYMENTS_MAX_AMOUNT_KOPEKS:
|
||||
await callback.answer(
|
||||
texts.t('AMOUNT_TOO_HIGH_SHORT', 'Сумма слишком велика'),
|
||||
show_alert=True,
|
||||
)
|
||||
return
|
||||
|
||||
await callback.answer()
|
||||
|
||||
# Create payment
|
||||
payment_service = PaymentService()
|
||||
|
||||
description = settings.PAYMENT_BALANCE_TEMPLATE.format(
|
||||
service_name=settings.PAYMENT_SERVICE_NAME,
|
||||
description=settings.CLOUDPAYMENTS_DESCRIPTION,
|
||||
)
|
||||
|
||||
result = await payment_service.create_cloudpayments_payment(
|
||||
db=db,
|
||||
user_id=db_user.id,
|
||||
amount_kopeks=amount_kopeks,
|
||||
description=description,
|
||||
telegram_id=db_user.telegram_id,
|
||||
language=db_user.language,
|
||||
)
|
||||
|
||||
if not result:
|
||||
await callback.message.edit_text(
|
||||
texts.t(
|
||||
'PAYMENT_CREATE_ERROR',
|
||||
'Не удалось создать платёж. Попробуйте позже.',
|
||||
),
|
||||
reply_markup=get_back_keyboard(db_user.language),
|
||||
parse_mode='HTML',
|
||||
)
|
||||
return
|
||||
|
||||
payment_url = result.get('payment_url')
|
||||
|
||||
# Create keyboard with payment button
|
||||
keyboard = InlineKeyboardMarkup(
|
||||
inline_keyboard=[
|
||||
[
|
||||
InlineKeyboardButton(
|
||||
text=texts.t(
|
||||
'PAY_BUTTON',
|
||||
'💳 Оплатить {amount}₽',
|
||||
).format(amount=f'{amount_rub:.0f}'),
|
||||
url=payment_url,
|
||||
)
|
||||
],
|
||||
[
|
||||
InlineKeyboardButton(
|
||||
text=texts.t('BACK_BUTTON', '◀️ Назад'),
|
||||
callback_data='menu_balance',
|
||||
)
|
||||
],
|
||||
]
|
||||
)
|
||||
|
||||
await callback.message.edit_text(
|
||||
texts.t(
|
||||
'CLOUDPAYMENTS_PAYMENT_CREATED',
|
||||
'💳 <b>Оплата банковской картой</b>\n\n'
|
||||
'Сумма: <b>{amount}₽</b>\n\n'
|
||||
'Нажмите кнопку ниже для оплаты.\n'
|
||||
'После успешной оплаты баланс будет пополнен автоматически.',
|
||||
).format(amount=f'{amount_rub:.2f}'),
|
||||
reply_markup=keyboard,
|
||||
parse_mode='HTML',
|
||||
)
|
||||
|
||||
logger.info(
|
||||
'CloudPayments payment created (quick): user amount=₽', telegram_id=db_user.telegram_id, amount_rub=amount_rub
|
||||
)
|
||||
|
||||
@@ -53,41 +53,18 @@ async def start_cryptobot_payment(callback: types.CallbackQuery, db_user: User,
|
||||
available_assets = settings.get_cryptobot_assets()
|
||||
assets_text = ', '.join(available_assets)
|
||||
|
||||
# Формируем текст сообщения в зависимости от настройки
|
||||
if settings.is_quick_amount_buttons_enabled():
|
||||
message_text = (
|
||||
f'🪙 <b>Пополнение криптовалютой</b>\n\n'
|
||||
f'Выберите сумму пополнения или введите вручную сумму '
|
||||
f'от 100 до 100,000 ₽:\n\n'
|
||||
f'💰 Доступные активы: {assets_text}\n'
|
||||
f'⚡ Мгновенное зачисление на баланс\n'
|
||||
f'🔒 Безопасная оплата через CryptoBot\n\n'
|
||||
f'{rate_text}\n'
|
||||
f'Сумма будет автоматически конвертирована в USD для оплаты.'
|
||||
)
|
||||
else:
|
||||
message_text = (
|
||||
f'🪙 <b>Пополнение криптовалютой</b>\n\n'
|
||||
f'Введите сумму для пополнения от 100 до 100,000 ₽:\n\n'
|
||||
f'💰 Доступные активы: {assets_text}\n'
|
||||
f'⚡ Мгновенное зачисление на баланс\n'
|
||||
f'🔒 Безопасная оплата через CryptoBot\n\n'
|
||||
f'{rate_text}\n'
|
||||
f'Сумма будет автоматически конвертирована в USD для оплаты.'
|
||||
)
|
||||
message_text = (
|
||||
f'🪙 <b>Пополнение криптовалютой</b>\n\n'
|
||||
f'Введите сумму для пополнения от 100 до 100,000 ₽:\n\n'
|
||||
f'💰 Доступные активы: {assets_text}\n'
|
||||
f'⚡ Мгновенное зачисление на баланс\n'
|
||||
f'🔒 Безопасная оплата через CryptoBot\n\n'
|
||||
f'{rate_text}\n'
|
||||
f'Сумма будет автоматически конвертирована в USD для оплаты.'
|
||||
)
|
||||
|
||||
# Создаем клавиатуру
|
||||
keyboard = get_back_keyboard(db_user.language)
|
||||
|
||||
# Если включен быстрый выбор суммы и не отключены кнопки, добавляем кнопки
|
||||
if settings.is_quick_amount_buttons_enabled():
|
||||
from .main import get_quick_amount_buttons
|
||||
|
||||
quick_amount_buttons = await get_quick_amount_buttons(db_user.language, db_user)
|
||||
if quick_amount_buttons:
|
||||
# Вставляем кнопки быстрого выбора перед кнопкой "Назад"
|
||||
keyboard.inline_keyboard = quick_amount_buttons + keyboard.inline_keyboard
|
||||
|
||||
await callback.message.edit_text(message_text, reply_markup=keyboard, parse_mode='HTML')
|
||||
|
||||
await state.set_state(BalanceStates.waiting_for_amount)
|
||||
|
||||
@@ -154,7 +154,7 @@ async def process_freekassa_payment_amount(
|
||||
payment_method: str | None = None,
|
||||
):
|
||||
"""
|
||||
Process payment amount directly (called from quick_amount handlers).
|
||||
Process payment amount directly.
|
||||
payment_method: 'freekassa', 'freekassa_sbp', 'freekassa_card'
|
||||
"""
|
||||
texts = get_texts(db_user.language)
|
||||
@@ -370,127 +370,3 @@ async def process_freekassa_custom_amount(
|
||||
state=state,
|
||||
payment_method=data.get('payment_method'),
|
||||
)
|
||||
|
||||
|
||||
async def _process_freekassa_quick_amount_impl(
|
||||
callback: types.CallbackQuery,
|
||||
db_user: User,
|
||||
db: AsyncSession,
|
||||
state: FSMContext,
|
||||
payment_method: str,
|
||||
):
|
||||
"""
|
||||
Process quick amount selection for Freekassa payment.
|
||||
Called when user clicks a predefined amount button.
|
||||
payment_method: 'freekassa', 'freekassa_sbp', 'freekassa_card'
|
||||
"""
|
||||
texts = get_texts(db_user.language)
|
||||
|
||||
if not settings.is_freekassa_enabled():
|
||||
await callback.answer(
|
||||
texts.t('FREEKASSA_NOT_AVAILABLE', 'Freekassa временно недоступен'),
|
||||
show_alert=True,
|
||||
)
|
||||
return
|
||||
|
||||
if payment_method == 'freekassa_sbp' and not settings.is_freekassa_sbp_enabled():
|
||||
await callback.answer(
|
||||
texts.t('FREEKASSA_NOT_AVAILABLE', 'Freekassa временно недоступен'),
|
||||
show_alert=True,
|
||||
)
|
||||
return
|
||||
|
||||
if payment_method == 'freekassa_card' and not settings.is_freekassa_card_enabled():
|
||||
await callback.answer(
|
||||
texts.t('FREEKASSA_NOT_AVAILABLE', 'Freekassa временно недоступен'),
|
||||
show_alert=True,
|
||||
)
|
||||
return
|
||||
|
||||
# Extract amount from callback data: topup_amount|{method}|{amount_kopeks}
|
||||
try:
|
||||
parts = callback.data.split('|')
|
||||
if len(parts) >= 3:
|
||||
amount_kopeks = int(parts[2])
|
||||
else:
|
||||
await callback.answer('Invalid callback data', show_alert=True)
|
||||
return
|
||||
except (ValueError, IndexError):
|
||||
await callback.answer('Invalid amount', show_alert=True)
|
||||
return
|
||||
|
||||
# Проверка ограничения на пополнение
|
||||
if getattr(db_user, 'restriction_topup', False):
|
||||
reason = getattr(db_user, 'restriction_reason', None) or 'Действие ограничено администратором'
|
||||
support_url = settings.get_support_contact_url()
|
||||
keyboard = []
|
||||
if support_url:
|
||||
keyboard.append([InlineKeyboardButton(text='🆘 Обжаловать', url=support_url)])
|
||||
keyboard.append([InlineKeyboardButton(text=texts.BACK, callback_data='menu_balance')])
|
||||
|
||||
await callback.message.edit_text(
|
||||
f'🚫 <b>Пополнение ограничено</b>\n\n{reason}',
|
||||
parse_mode='HTML',
|
||||
reply_markup=InlineKeyboardMarkup(inline_keyboard=keyboard),
|
||||
)
|
||||
return
|
||||
|
||||
# Validate amount
|
||||
min_amount = settings.FREEKASSA_MIN_AMOUNT_KOPEKS
|
||||
max_amount = settings.FREEKASSA_MAX_AMOUNT_KOPEKS
|
||||
|
||||
if amount_kopeks < min_amount:
|
||||
await callback.answer(
|
||||
texts.t('AMOUNT_TOO_LOW_SHORT', 'Сумма слишком мала'),
|
||||
show_alert=True,
|
||||
)
|
||||
return
|
||||
|
||||
if amount_kopeks > max_amount:
|
||||
await callback.answer(
|
||||
texts.t('AMOUNT_TOO_HIGH_SHORT', 'Сумма слишком велика'),
|
||||
show_alert=True,
|
||||
)
|
||||
return
|
||||
|
||||
await callback.answer()
|
||||
await state.clear()
|
||||
|
||||
await _create_freekassa_payment_and_respond(
|
||||
message_or_callback=callback.message,
|
||||
db_user=db_user,
|
||||
db=db,
|
||||
amount_kopeks=amount_kopeks,
|
||||
edit_message=True,
|
||||
payment_method=payment_method,
|
||||
)
|
||||
|
||||
|
||||
@error_handler
|
||||
async def process_freekassa_quick_amount(
|
||||
callback: types.CallbackQuery,
|
||||
db_user: User,
|
||||
db: AsyncSession,
|
||||
state: FSMContext,
|
||||
):
|
||||
await _process_freekassa_quick_amount_impl(callback, db_user, db, state, 'freekassa')
|
||||
|
||||
|
||||
@error_handler
|
||||
async def process_freekassa_sbp_quick_amount(
|
||||
callback: types.CallbackQuery,
|
||||
db_user: User,
|
||||
db: AsyncSession,
|
||||
state: FSMContext,
|
||||
):
|
||||
await _process_freekassa_quick_amount_impl(callback, db_user, db, state, 'freekassa_sbp')
|
||||
|
||||
|
||||
@error_handler
|
||||
async def process_freekassa_card_quick_amount(
|
||||
callback: types.CallbackQuery,
|
||||
db_user: User,
|
||||
db: AsyncSession,
|
||||
state: FSMContext,
|
||||
):
|
||||
await _process_freekassa_quick_amount_impl(callback, db_user, db, state, 'freekassa_card')
|
||||
|
||||
@@ -72,13 +72,6 @@ async def start_heleket_payment(
|
||||
|
||||
keyboard = get_back_keyboard(db_user.language)
|
||||
|
||||
if settings.is_quick_amount_buttons_enabled():
|
||||
from .main import get_quick_amount_buttons
|
||||
|
||||
quick_buttons = await get_quick_amount_buttons(db_user.language, db_user)
|
||||
if quick_buttons:
|
||||
keyboard.inline_keyboard = quick_buttons + keyboard.inline_keyboard
|
||||
|
||||
await callback.message.edit_text(
|
||||
'\n'.join(filter(None, message_lines)),
|
||||
reply_markup=keyboard,
|
||||
|
||||
@@ -168,7 +168,7 @@ async def process_kassa_ai_payment_amount(
|
||||
state: FSMContext,
|
||||
payment_method: str = 'kassa_ai',
|
||||
):
|
||||
"""Process payment amount directly (called from custom_amount and quick_amount handlers)."""
|
||||
"""Process payment amount directly (called from custom_amount handlers)."""
|
||||
texts = get_texts(db_user.language)
|
||||
|
||||
# Проверка ограничения на пополнение
|
||||
@@ -275,54 +275,6 @@ async def _start_kassa_ai_sub_topup(
|
||||
)
|
||||
|
||||
|
||||
async def _process_kassa_ai_sub_quick_amount(
|
||||
callback: types.CallbackQuery,
|
||||
db_user: User,
|
||||
db: AsyncSession,
|
||||
state: FSMContext,
|
||||
payment_method: str,
|
||||
):
|
||||
"""Generic quick amount handler for any KassaAI sub-method."""
|
||||
cfg = _KASSA_AI_METHOD_CONFIG[payment_method]
|
||||
texts = get_texts(db_user.language)
|
||||
|
||||
if not cfg['is_enabled']():
|
||||
await callback.answer(texts.t('KASSA_AI_NOT_AVAILABLE', cfg['unavailable_text']), show_alert=True)
|
||||
return
|
||||
|
||||
try:
|
||||
parts = callback.data.split('|')
|
||||
amount_kopeks = int(parts[2]) if len(parts) >= 3 else None
|
||||
if amount_kopeks is None:
|
||||
raise ValueError
|
||||
except (ValueError, IndexError):
|
||||
await callback.answer('Invalid amount', show_alert=True)
|
||||
return
|
||||
|
||||
if await _check_topup_restriction(callback, db_user):
|
||||
return
|
||||
|
||||
min_amount = settings.KASSA_AI_MIN_AMOUNT_KOPEKS
|
||||
max_amount = settings.KASSA_AI_MAX_AMOUNT_KOPEKS
|
||||
if amount_kopeks < min_amount:
|
||||
await callback.answer(texts.t('AMOUNT_TOO_LOW_SHORT', 'Сумма слишком мала'), show_alert=True)
|
||||
return
|
||||
if amount_kopeks > max_amount:
|
||||
await callback.answer(texts.t('AMOUNT_TOO_HIGH_SHORT', 'Сумма слишком велика'), show_alert=True)
|
||||
return
|
||||
|
||||
await callback.answer()
|
||||
await state.clear()
|
||||
await _create_kassa_ai_payment_and_respond(
|
||||
message_or_callback=callback.message,
|
||||
db_user=db_user,
|
||||
db=db,
|
||||
amount_kopeks=amount_kopeks,
|
||||
edit_message=True,
|
||||
payment_method=payment_method,
|
||||
)
|
||||
|
||||
|
||||
# --- Public handler functions (registered in main.py) ---
|
||||
|
||||
|
||||
@@ -376,17 +328,6 @@ async def process_kassa_ai_custom_amount(
|
||||
)
|
||||
|
||||
|
||||
@error_handler
|
||||
async def process_kassa_ai_quick_amount(
|
||||
callback: types.CallbackQuery,
|
||||
db_user: User,
|
||||
db: AsyncSession,
|
||||
state: FSMContext,
|
||||
):
|
||||
"""Process quick amount selection for KassaAI payment."""
|
||||
await _process_kassa_ai_sub_quick_amount(callback, db_user, db, state, 'kassa_ai')
|
||||
|
||||
|
||||
@error_handler
|
||||
async def start_kassa_ai_sbp_topup(
|
||||
callback: types.CallbackQuery,
|
||||
@@ -398,17 +339,6 @@ async def start_kassa_ai_sbp_topup(
|
||||
await _start_kassa_ai_sub_topup(callback, db_user, db, state, 'kassa_ai_sbp')
|
||||
|
||||
|
||||
@error_handler
|
||||
async def process_kassa_ai_sbp_quick_amount(
|
||||
callback: types.CallbackQuery,
|
||||
db_user: User,
|
||||
db: AsyncSession,
|
||||
state: FSMContext,
|
||||
):
|
||||
"""Process quick amount for KassaAI SBP."""
|
||||
await _process_kassa_ai_sub_quick_amount(callback, db_user, db, state, 'kassa_ai_sbp')
|
||||
|
||||
|
||||
@error_handler
|
||||
async def start_kassa_ai_card_topup(
|
||||
callback: types.CallbackQuery,
|
||||
@@ -418,14 +348,3 @@ async def start_kassa_ai_card_topup(
|
||||
):
|
||||
"""Start KassaAI Card top-up process."""
|
||||
await _start_kassa_ai_sub_topup(callback, db_user, db, state, 'kassa_ai_card')
|
||||
|
||||
|
||||
@error_handler
|
||||
async def process_kassa_ai_card_quick_amount(
|
||||
callback: types.CallbackQuery,
|
||||
db_user: User,
|
||||
db: AsyncSession,
|
||||
state: FSMContext,
|
||||
):
|
||||
"""Process quick amount for KassaAI Card."""
|
||||
await _process_kassa_ai_sub_quick_amount(callback, db_user, db, state, 'kassa_ai_card')
|
||||
|
||||
+17
-149
@@ -157,102 +157,6 @@ async def route_payment_by_method(
|
||||
return False
|
||||
|
||||
|
||||
async def get_quick_amount_buttons(language: str, user: User) -> list:
|
||||
"""
|
||||
Generate quick amount buttons with user-specific pricing and discounts.
|
||||
|
||||
Uses PricingEngine as the single source of truth for all price calculations,
|
||||
including base period price, devices, servers, traffic, and per-category discounts.
|
||||
|
||||
Args:
|
||||
language: User's language for formatting
|
||||
user: User object to calculate personalized discounts
|
||||
|
||||
Returns:
|
||||
List of button rows for inline keyboard
|
||||
"""
|
||||
if not settings.is_quick_amount_buttons_enabled():
|
||||
return []
|
||||
|
||||
from app.database.crud.subscription import get_subscription_by_user_id
|
||||
from app.database.database import AsyncSessionLocal
|
||||
from app.services.pricing_engine import pricing_engine
|
||||
|
||||
texts = get_texts(language)
|
||||
|
||||
buttons = []
|
||||
|
||||
async with AsyncSessionLocal() as db:
|
||||
subscription = await get_subscription_by_user_id(db, user.id)
|
||||
|
||||
tariff = None
|
||||
tariff_periods = None
|
||||
if settings.is_tariffs_mode() and subscription and subscription.tariff_id:
|
||||
tariff = subscription.tariff
|
||||
if tariff and tariff.period_prices:
|
||||
tariff_periods = sorted(int(k) for k in tariff.period_prices.keys())
|
||||
|
||||
if tariff_periods:
|
||||
periods = tariff_periods[:6]
|
||||
else:
|
||||
periods = settings.get_available_subscription_periods()[:6]
|
||||
|
||||
for period in periods:
|
||||
try:
|
||||
if tariff and tariff_periods and period in tariff_periods:
|
||||
result = await pricing_engine.calculate_tariff_purchase_price(
|
||||
tariff,
|
||||
period,
|
||||
device_limit=subscription.device_limit if subscription else None,
|
||||
user=user,
|
||||
)
|
||||
elif subscription:
|
||||
result = await pricing_engine.calculate_renewal_price(db, subscription, period, user=user)
|
||||
else:
|
||||
result = await pricing_engine.calculate_classic_new_subscription_price(
|
||||
db,
|
||||
period,
|
||||
[],
|
||||
0,
|
||||
settings.DEFAULT_DEVICE_LIMIT,
|
||||
user=user,
|
||||
)
|
||||
|
||||
total_price = result.final_total
|
||||
original_total = result.original_total
|
||||
|
||||
if total_price <= 0:
|
||||
continue
|
||||
|
||||
callback_data = f'quick_amount_{total_price}'
|
||||
period_label = f'{period} дней'
|
||||
|
||||
has_discount = original_total > total_price and original_total > 0
|
||||
if has_discount:
|
||||
discount_pct = round((original_total - total_price) * 100 / original_total)
|
||||
if discount_pct > 0:
|
||||
button_text = (
|
||||
f'{texts.format_price(original_total)} ➜ '
|
||||
f'{texts.format_price(total_price)} '
|
||||
f'(-{discount_pct}%) • {period_label}'
|
||||
)
|
||||
else:
|
||||
button_text = f'{texts.format_price(total_price)} • {period_label}'
|
||||
else:
|
||||
button_text = f'{texts.format_price(total_price)} • {period_label}'
|
||||
|
||||
buttons.append(types.InlineKeyboardButton(text=button_text, callback_data=callback_data))
|
||||
except Exception:
|
||||
logger.warning('Failed to calculate price for period', period=period)
|
||||
continue
|
||||
|
||||
keyboard_rows = []
|
||||
for i in range(0, len(buttons), 2):
|
||||
keyboard_rows.append(buttons[i : i + 2])
|
||||
|
||||
return keyboard_rows
|
||||
|
||||
|
||||
@error_handler
|
||||
async def show_balance_menu(callback: types.CallbackQuery, db_user: User, db: AsyncSession):
|
||||
# Проверяем, доступно ли сообщение
|
||||
@@ -379,9 +283,22 @@ async def show_payment_methods(callback: types.CallbackQuery, db_user: User, db:
|
||||
|
||||
payment_text = get_payment_methods_text(db_user.language)
|
||||
|
||||
# Проверяем сохранённую корзину для автоподстановки суммы пополнения
|
||||
amount_kopeks = 0
|
||||
try:
|
||||
from app.services.user_cart_service import user_cart_service
|
||||
|
||||
cart_data = await user_cart_service.get_user_cart(db_user.id)
|
||||
if cart_data and cart_data.get('saved_cart'):
|
||||
missing = cart_data.get('missing_amount', 0)
|
||||
if missing > 0:
|
||||
amount_kopeks = missing
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
full_text = payment_text
|
||||
|
||||
keyboard = get_payment_methods_keyboard(0, db_user.language)
|
||||
keyboard = get_payment_methods_keyboard(amount_kopeks, db_user.language)
|
||||
|
||||
# Если сообщение недоступно, отправляем новое
|
||||
if isinstance(callback.message, InaccessibleMessage):
|
||||
@@ -627,37 +544,6 @@ async def handle_sbp_payment(callback: types.CallbackQuery, db: AsyncSession):
|
||||
await callback.answer('❌ Ошибка обработки платежа', show_alert=True)
|
||||
|
||||
|
||||
@error_handler
|
||||
async def handle_quick_amount_selection(callback: types.CallbackQuery, db_user: User, state: FSMContext):
|
||||
"""
|
||||
Обработчик выбора суммы через кнопки быстрого выбора
|
||||
"""
|
||||
# Проверяем, что пользователь в правильном состоянии FSM
|
||||
current_state = await state.get_state()
|
||||
if current_state != BalanceStates.waiting_for_amount:
|
||||
await callback.answer('❌ Сначала выберите способ оплаты', show_alert=True)
|
||||
return
|
||||
|
||||
# Извлекаем сумму из callback_data
|
||||
try:
|
||||
amount_kopeks = int(callback.data.split('_')[-1])
|
||||
|
||||
# Получаем метод оплаты из состояния
|
||||
data = await state.get_data()
|
||||
payment_method = data.get('payment_method', 'yookassa')
|
||||
|
||||
# Роутим платеж на соответствующий обработчик
|
||||
if not await route_payment_by_method(callback.message, db_user, amount_kopeks, state, payment_method):
|
||||
await callback.answer('❌ Неизвестный способ оплаты', show_alert=True)
|
||||
return
|
||||
|
||||
except ValueError:
|
||||
await callback.answer('❌ Ошибка обработки суммы', show_alert=True)
|
||||
except Exception as e:
|
||||
logger.error('Ошибка обработки быстрого выбора суммы', error=e)
|
||||
await callback.answer('❌ Ошибка обработки запроса', show_alert=True)
|
||||
|
||||
|
||||
@error_handler
|
||||
async def handle_topup_amount_callback(
|
||||
callback: types.CallbackQuery,
|
||||
@@ -784,52 +670,37 @@ def register_balance_handlers(dp: Dispatcher):
|
||||
dp.callback_query.register(start_heleket_payment, F.data == 'topup_heleket')
|
||||
dp.callback_query.register(check_heleket_payment_status, F.data.startswith('check_heleket_'))
|
||||
|
||||
from .cloudpayments import handle_cloudpayments_quick_amount, start_cloudpayments_payment
|
||||
from .cloudpayments import start_cloudpayments_payment
|
||||
|
||||
dp.callback_query.register(start_cloudpayments_payment, F.data == 'topup_cloudpayments')
|
||||
dp.callback_query.register(handle_cloudpayments_quick_amount, F.data.startswith('topup_amount|cloudpayments|'))
|
||||
|
||||
from .freekassa import (
|
||||
process_freekassa_card_quick_amount,
|
||||
process_freekassa_quick_amount,
|
||||
process_freekassa_sbp_quick_amount,
|
||||
start_freekassa_card_topup,
|
||||
start_freekassa_sbp_topup,
|
||||
start_freekassa_topup,
|
||||
)
|
||||
|
||||
dp.callback_query.register(start_freekassa_topup, F.data == 'topup_freekassa')
|
||||
dp.callback_query.register(process_freekassa_quick_amount, F.data.startswith('topup_amount|freekassa|'))
|
||||
dp.callback_query.register(start_freekassa_sbp_topup, F.data == 'topup_freekassa_sbp')
|
||||
dp.callback_query.register(process_freekassa_sbp_quick_amount, F.data.startswith('topup_amount|freekassa_sbp|'))
|
||||
dp.callback_query.register(start_freekassa_card_topup, F.data == 'topup_freekassa_card')
|
||||
dp.callback_query.register(process_freekassa_card_quick_amount, F.data.startswith('topup_amount|freekassa_card|'))
|
||||
|
||||
from .kassa_ai import (
|
||||
process_kassa_ai_card_quick_amount,
|
||||
process_kassa_ai_quick_amount,
|
||||
process_kassa_ai_sbp_quick_amount,
|
||||
start_kassa_ai_card_topup,
|
||||
start_kassa_ai_sbp_topup,
|
||||
start_kassa_ai_topup,
|
||||
)
|
||||
|
||||
dp.callback_query.register(start_kassa_ai_topup, F.data == 'topup_kassa_ai')
|
||||
dp.callback_query.register(process_kassa_ai_quick_amount, F.data.startswith('topup_amount|kassa_ai|'))
|
||||
dp.callback_query.register(start_kassa_ai_sbp_topup, F.data == 'topup_kassa_ai_sbp')
|
||||
dp.callback_query.register(process_kassa_ai_sbp_quick_amount, F.data.startswith('topup_amount|kassa_ai_sbp|'))
|
||||
dp.callback_query.register(start_kassa_ai_card_topup, F.data == 'topup_kassa_ai_card')
|
||||
dp.callback_query.register(process_kassa_ai_card_quick_amount, F.data.startswith('topup_amount|kassa_ai_card|'))
|
||||
|
||||
from .riopay import process_riopay_quick_amount, start_riopay_topup
|
||||
from .riopay import start_riopay_topup
|
||||
|
||||
dp.callback_query.register(start_riopay_topup, F.data == 'topup_riopay')
|
||||
dp.callback_query.register(process_riopay_quick_amount, F.data.startswith('topup_amount|riopay|'))
|
||||
|
||||
from .severpay import process_severpay_quick_amount, start_severpay_topup
|
||||
from .severpay import start_severpay_topup
|
||||
|
||||
dp.callback_query.register(start_severpay_topup, F.data == 'topup_severpay')
|
||||
dp.callback_query.register(process_severpay_quick_amount, F.data.startswith('topup_amount|severpay|'))
|
||||
|
||||
from .mulenpay import check_mulenpay_payment_status
|
||||
|
||||
@@ -849,9 +720,6 @@ def register_balance_handlers(dp: Dispatcher):
|
||||
|
||||
dp.callback_query.register(handle_payment_methods_unavailable, F.data == 'payment_methods_unavailable')
|
||||
|
||||
# Регистрируем обработчик для кнопок быстрого выбора суммы
|
||||
dp.callback_query.register(handle_quick_amount_selection, F.data.startswith('quick_amount_'))
|
||||
|
||||
dp.callback_query.register(handle_topup_amount_callback, F.data.startswith('topup_amount|'))
|
||||
|
||||
dp.callback_query.register(handle_saved_cards_list, F.data == 'saved_cards_list')
|
||||
|
||||
@@ -65,13 +65,6 @@ async def start_mulenpay_payment(
|
||||
|
||||
keyboard = get_back_keyboard(db_user.language)
|
||||
|
||||
if settings.is_quick_amount_buttons_enabled():
|
||||
from .main import get_quick_amount_buttons
|
||||
|
||||
quick_amount_buttons = await get_quick_amount_buttons(db_user.language, db_user)
|
||||
if quick_amount_buttons:
|
||||
keyboard.inline_keyboard = quick_amount_buttons + keyboard.inline_keyboard
|
||||
|
||||
await callback.message.edit_text(
|
||||
message_text,
|
||||
reply_markup=keyboard,
|
||||
|
||||
@@ -303,13 +303,6 @@ async def start_pal24_payment(
|
||||
|
||||
keyboard = get_back_keyboard(db_user.language)
|
||||
|
||||
if settings.is_quick_amount_buttons_enabled():
|
||||
from .main import get_quick_amount_buttons
|
||||
|
||||
quick_amount_buttons = await get_quick_amount_buttons(db_user.language, db_user)
|
||||
if quick_amount_buttons:
|
||||
keyboard.inline_keyboard = quick_amount_buttons + keyboard.inline_keyboard
|
||||
|
||||
await callback.message.edit_text(
|
||||
message_text,
|
||||
reply_markup=keyboard,
|
||||
|
||||
@@ -71,13 +71,6 @@ async def _prompt_amount(
|
||||
|
||||
keyboard = get_back_keyboard(db_user.language)
|
||||
|
||||
if settings.is_quick_amount_buttons_enabled():
|
||||
from .main import get_quick_amount_buttons
|
||||
|
||||
quick_amount_buttons = await get_quick_amount_buttons(db_user.language, db_user)
|
||||
if quick_amount_buttons:
|
||||
keyboard.inline_keyboard = quick_amount_buttons + keyboard.inline_keyboard
|
||||
|
||||
await message.edit_text(
|
||||
prompt_template.format(
|
||||
method_name=method_name,
|
||||
|
||||
@@ -136,7 +136,7 @@ async def process_riopay_payment_amount(
|
||||
state: FSMContext,
|
||||
):
|
||||
"""
|
||||
Process payment amount directly (called from quick_amount handlers).
|
||||
Process payment amount directly.
|
||||
"""
|
||||
texts = get_texts(db_user.language)
|
||||
|
||||
@@ -282,75 +282,3 @@ async def process_riopay_custom_amount(
|
||||
amount_kopeks=amount_kopeks,
|
||||
state=state,
|
||||
)
|
||||
|
||||
|
||||
@error_handler
|
||||
async def process_riopay_quick_amount(
|
||||
callback: types.CallbackQuery,
|
||||
db_user: User,
|
||||
db: AsyncSession,
|
||||
state: FSMContext,
|
||||
):
|
||||
"""
|
||||
Process quick amount selection for RioPay payment.
|
||||
Called when user clicks a predefined amount button.
|
||||
"""
|
||||
texts = get_texts(db_user.language)
|
||||
|
||||
if not settings.is_riopay_enabled():
|
||||
await callback.answer(
|
||||
texts.t('RIOPAY_NOT_AVAILABLE', 'RioPay временно недоступен'),
|
||||
show_alert=True,
|
||||
)
|
||||
return
|
||||
|
||||
# Extract amount from callback data: topup_amount|riopay|{amount_kopeks}
|
||||
try:
|
||||
parts = callback.data.split('|')
|
||||
if len(parts) >= 3:
|
||||
amount_kopeks = int(parts[2])
|
||||
else:
|
||||
await callback.answer('Invalid callback data', show_alert=True)
|
||||
return
|
||||
except (ValueError, IndexError):
|
||||
await callback.answer('Invalid amount', show_alert=True)
|
||||
return
|
||||
|
||||
restriction_kb = _check_topup_restriction(db_user, texts)
|
||||
if restriction_kb:
|
||||
reason = getattr(db_user, 'restriction_reason', None) or 'Действие ограничено администратором'
|
||||
await callback.message.edit_text(
|
||||
f'🚫 <b>Пополнение ограничено</b>\n\n{reason}',
|
||||
parse_mode='HTML',
|
||||
reply_markup=restriction_kb,
|
||||
)
|
||||
return
|
||||
|
||||
# Validate amount
|
||||
min_amount = settings.RIOPAY_MIN_AMOUNT_KOPEKS
|
||||
max_amount = settings.RIOPAY_MAX_AMOUNT_KOPEKS
|
||||
|
||||
if amount_kopeks < min_amount:
|
||||
await callback.answer(
|
||||
texts.t('AMOUNT_TOO_LOW_SHORT', 'Сумма слишком мала'),
|
||||
show_alert=True,
|
||||
)
|
||||
return
|
||||
|
||||
if amount_kopeks > max_amount:
|
||||
await callback.answer(
|
||||
texts.t('AMOUNT_TOO_HIGH_SHORT', 'Сумма слишком велика'),
|
||||
show_alert=True,
|
||||
)
|
||||
return
|
||||
|
||||
await callback.answer()
|
||||
await state.clear()
|
||||
|
||||
await _create_riopay_payment_and_respond(
|
||||
message_or_callback=callback.message,
|
||||
db_user=db_user,
|
||||
db=db,
|
||||
amount_kopeks=amount_kopeks,
|
||||
edit_message=True,
|
||||
)
|
||||
|
||||
@@ -136,7 +136,7 @@ async def process_severpay_payment_amount(
|
||||
state: FSMContext,
|
||||
):
|
||||
"""
|
||||
Process payment amount directly (called from quick_amount handlers).
|
||||
Process payment amount directly.
|
||||
"""
|
||||
texts = get_texts(db_user.language)
|
||||
|
||||
@@ -243,75 +243,3 @@ async def start_severpay_topup(
|
||||
parse_mode='HTML',
|
||||
reply_markup=keyboard,
|
||||
)
|
||||
|
||||
|
||||
@error_handler
|
||||
async def process_severpay_quick_amount(
|
||||
callback: types.CallbackQuery,
|
||||
db_user: User,
|
||||
db: AsyncSession,
|
||||
state: FSMContext,
|
||||
):
|
||||
"""
|
||||
Process quick amount selection for SeverPay payment.
|
||||
Called when user clicks a predefined amount button.
|
||||
"""
|
||||
texts = get_texts(db_user.language)
|
||||
|
||||
if not settings.is_severpay_enabled():
|
||||
await callback.answer(
|
||||
texts.t('SEVERPAY_NOT_AVAILABLE', 'SeverPay временно недоступен'),
|
||||
show_alert=True,
|
||||
)
|
||||
return
|
||||
|
||||
# Extract amount from callback data: topup_amount|severpay|{amount_kopeks}
|
||||
try:
|
||||
parts = callback.data.split('|')
|
||||
if len(parts) >= 3:
|
||||
amount_kopeks = int(parts[2])
|
||||
else:
|
||||
await callback.answer('Invalid callback data', show_alert=True)
|
||||
return
|
||||
except (ValueError, IndexError):
|
||||
await callback.answer('Invalid amount', show_alert=True)
|
||||
return
|
||||
|
||||
restriction_kb = _check_topup_restriction(db_user, texts)
|
||||
if restriction_kb:
|
||||
reason = getattr(db_user, 'restriction_reason', None) or 'Действие ограничено администратором'
|
||||
await callback.message.edit_text(
|
||||
f'🚫 <b>Пополнение ограничено</b>\n\n{reason}',
|
||||
parse_mode='HTML',
|
||||
reply_markup=restriction_kb,
|
||||
)
|
||||
return
|
||||
|
||||
# Validate amount
|
||||
min_amount = settings.SEVERPAY_MIN_AMOUNT_KOPEKS
|
||||
max_amount = settings.SEVERPAY_MAX_AMOUNT_KOPEKS
|
||||
|
||||
if amount_kopeks < min_amount:
|
||||
await callback.answer(
|
||||
texts.t('AMOUNT_TOO_LOW_SHORT', 'Сумма слишком мала'),
|
||||
show_alert=True,
|
||||
)
|
||||
return
|
||||
|
||||
if amount_kopeks > max_amount:
|
||||
await callback.answer(
|
||||
texts.t('AMOUNT_TOO_HIGH_SHORT', 'Сумма слишком велика'),
|
||||
show_alert=True,
|
||||
)
|
||||
return
|
||||
|
||||
await callback.answer()
|
||||
await state.clear()
|
||||
|
||||
await _create_severpay_payment_and_respond(
|
||||
message_or_callback=callback.message,
|
||||
db_user=db_user,
|
||||
db=db,
|
||||
amount_kopeks=amount_kopeks,
|
||||
edit_message=True,
|
||||
)
|
||||
|
||||
@@ -40,24 +40,10 @@ async def start_stars_payment(callback: types.CallbackQuery, db_user: User, stat
|
||||
await callback.answer()
|
||||
return
|
||||
|
||||
# Формируем текст сообщения в зависимости от настройки
|
||||
if settings.is_quick_amount_buttons_enabled():
|
||||
message_text = '⭐ <b>Пополнение через Telegram Stars</b>\n\nВыберите сумму пополнения или введите вручную:'
|
||||
else:
|
||||
message_text = texts.TOP_UP_AMOUNT
|
||||
message_text = texts.TOP_UP_AMOUNT
|
||||
|
||||
# Создаем клавиатуру
|
||||
keyboard = get_back_keyboard(db_user.language)
|
||||
|
||||
# Если включен быстрый выбор суммы и не отключены кнопки, добавляем кнопки
|
||||
if settings.is_quick_amount_buttons_enabled():
|
||||
from .main import get_quick_amount_buttons
|
||||
|
||||
quick_amount_buttons = await get_quick_amount_buttons(db_user.language, db_user)
|
||||
if quick_amount_buttons:
|
||||
# Вставляем кнопки быстрого выбора перед кнопкой "Назад"
|
||||
keyboard.inline_keyboard = quick_amount_buttons + keyboard.inline_keyboard
|
||||
|
||||
await callback.message.edit_text(message_text, reply_markup=keyboard)
|
||||
|
||||
await state.update_data(
|
||||
|
||||
@@ -61,13 +61,6 @@ async def start_wata_payment(
|
||||
|
||||
keyboard = get_back_keyboard(db_user.language)
|
||||
|
||||
if settings.is_quick_amount_buttons_enabled():
|
||||
from .main import get_quick_amount_buttons
|
||||
|
||||
quick_amount_buttons = await get_quick_amount_buttons(db_user.language, db_user)
|
||||
if quick_amount_buttons:
|
||||
keyboard.inline_keyboard = quick_amount_buttons + keyboard.inline_keyboard
|
||||
|
||||
await callback.message.edit_text(
|
||||
message_text,
|
||||
reply_markup=keyboard,
|
||||
|
||||
@@ -46,31 +46,13 @@ async def start_yookassa_payment(callback: types.CallbackQuery, db_user: User, s
|
||||
min_amount_rub = settings.YOOKASSA_MIN_AMOUNT_KOPEKS / 100
|
||||
max_amount_rub = settings.YOOKASSA_MAX_AMOUNT_KOPEKS / 100
|
||||
|
||||
# Формируем текст сообщения в зависимости от настройки
|
||||
if settings.is_quick_amount_buttons_enabled():
|
||||
message_text = (
|
||||
f'💳 <b>Оплата банковской картой</b>\n\n'
|
||||
f'Выберите сумму пополнения или введите вручную сумму '
|
||||
f'от {min_amount_rub:.0f} до {max_amount_rub:,.0f} рублей:'
|
||||
)
|
||||
else:
|
||||
message_text = (
|
||||
f'💳 <b>Оплата банковской картой</b>\n\n'
|
||||
f'Введите сумму для пополнения от {min_amount_rub:.0f} до {max_amount_rub:,.0f} рублей:'
|
||||
)
|
||||
message_text = (
|
||||
f'💳 <b>Оплата банковской картой</b>\n\n'
|
||||
f'Введите сумму для пополнения от {min_amount_rub:.0f} до {max_amount_rub:,.0f} рублей:'
|
||||
)
|
||||
|
||||
# Создаем клавиатуру
|
||||
keyboard = get_back_keyboard(db_user.language)
|
||||
|
||||
# Если включен быстрый выбор суммы и не отключены кнопки, добавляем кнопки
|
||||
if settings.is_quick_amount_buttons_enabled():
|
||||
from .main import get_quick_amount_buttons
|
||||
|
||||
quick_amount_buttons = await get_quick_amount_buttons(db_user.language, db_user)
|
||||
if quick_amount_buttons:
|
||||
# Вставляем кнопки быстрого выбора перед кнопкой "Назад"
|
||||
keyboard.inline_keyboard = quick_amount_buttons + keyboard.inline_keyboard
|
||||
|
||||
await callback.message.edit_text(message_text, reply_markup=keyboard, parse_mode='HTML')
|
||||
|
||||
await state.set_state(BalanceStates.waiting_for_amount)
|
||||
@@ -110,31 +92,13 @@ async def start_yookassa_sbp_payment(callback: types.CallbackQuery, db_user: Use
|
||||
min_amount_rub = settings.YOOKASSA_MIN_AMOUNT_KOPEKS / 100
|
||||
max_amount_rub = settings.YOOKASSA_MAX_AMOUNT_KOPEKS / 100
|
||||
|
||||
# Формируем текст сообщения в зависимости от настройки
|
||||
if settings.is_quick_amount_buttons_enabled():
|
||||
message_text = (
|
||||
f'🏦 <b>Оплата через СБП</b>\n\n'
|
||||
f'Выберите сумму пополнения или введите вручную сумму '
|
||||
f'от {min_amount_rub:.0f} до {max_amount_rub:,.0f} рублей:'
|
||||
)
|
||||
else:
|
||||
message_text = (
|
||||
f'🏦 <b>Оплата через СБП</b>\n\n'
|
||||
f'Введите сумму для пополнения от {min_amount_rub:.0f} до {max_amount_rub:,.0f} рублей:'
|
||||
)
|
||||
message_text = (
|
||||
f'🏦 <b>Оплата через СБП</b>\n\n'
|
||||
f'Введите сумму для пополнения от {min_amount_rub:.0f} до {max_amount_rub:,.0f} рублей:'
|
||||
)
|
||||
|
||||
# Создаем клавиатуру
|
||||
keyboard = get_back_keyboard(db_user.language)
|
||||
|
||||
# Если включен быстрый выбор суммы и не отключены кнопки, добавляем кнопки
|
||||
if settings.is_quick_amount_buttons_enabled():
|
||||
from .main import get_quick_amount_buttons
|
||||
|
||||
quick_amount_buttons = await get_quick_amount_buttons(db_user.language, db_user)
|
||||
if quick_amount_buttons:
|
||||
# Вставляем кнопки быстрого выбора перед кнопкой "Назад"
|
||||
keyboard.inline_keyboard = quick_amount_buttons + keyboard.inline_keyboard
|
||||
|
||||
await callback.message.edit_text(message_text, reply_markup=keyboard, parse_mode='HTML')
|
||||
|
||||
await state.set_state(BalanceStates.waiting_for_amount)
|
||||
|
||||
@@ -14,7 +14,7 @@ from app.config import settings
|
||||
from app.database.models import User
|
||||
from app.keyboards.inline import get_referral_keyboard
|
||||
from app.localization.texts import get_texts
|
||||
from app.services.admin_notification_service import AdminNotificationService
|
||||
from app.services.admin_notification_service import AdminNotificationService, NotificationCategory
|
||||
from app.services.referral_withdrawal_service import referral_withdrawal_service
|
||||
from app.states import ReferralWithdrawalStates
|
||||
from app.utils.photo_message import edit_or_answer_photo
|
||||
@@ -825,7 +825,9 @@ async def confirm_withdrawal_request(callback: types.CallbackQuery, db_user: Use
|
||||
|
||||
try:
|
||||
notification_service = AdminNotificationService(callback.bot)
|
||||
await notification_service.send_admin_notification(admin_text, reply_markup=admin_keyboard)
|
||||
await notification_service.send_admin_notification(
|
||||
admin_text, reply_markup=admin_keyboard, category=NotificationCategory.PARTNERS
|
||||
)
|
||||
except Exception as e:
|
||||
logger.error('Ошибка отправки уведомления админам о заявке на вывод', error=e)
|
||||
|
||||
|
||||
@@ -354,10 +354,6 @@ async def _handle_guest_purchase_payment(
|
||||
stars_amount=stars_amount,
|
||||
purchase_token_prefix=purchase_token[:5],
|
||||
)
|
||||
elif result is False:
|
||||
await message.answer(
|
||||
'❌ Произошла ошибка при обработке подарочной подписки. Обратитесь в поддержку.',
|
||||
)
|
||||
else:
|
||||
logger.error('try_fulfill_guest_purchase returned None for Stars gift', payload=payload)
|
||||
await message.answer('❌ Ошибка обработки платежа. Обратитесь в поддержку.')
|
||||
|
||||
@@ -497,6 +497,12 @@ async def execute_change_devices(callback: types.CallbackQuery, db_user: User, d
|
||||
|
||||
texts = get_texts(db_user.language)
|
||||
subscription = db_user.subscription
|
||||
if not subscription:
|
||||
await callback.answer(
|
||||
texts.t('NO_ACTIVE_SUBSCRIPTION', '⚠️ У вас нет активной подписки'),
|
||||
show_alert=True,
|
||||
)
|
||||
return
|
||||
current_devices = subscription.device_limit
|
||||
|
||||
# Проверяем тариф подписки
|
||||
|
||||
@@ -1469,7 +1469,7 @@ async def confirm_daily_tariff_purchase(
|
||||
try:
|
||||
if existing_subscription:
|
||||
# Обновляем существующую подписку на суточный тариф
|
||||
# Сохраняем докупленные устройства при смене тарифа
|
||||
# Сбрасываем лимит устройств на базу нового тарифа (докупленные не переносятся)
|
||||
from app.database.crud.subscription import calc_device_limit_on_tariff_switch
|
||||
|
||||
old_tariff = (
|
||||
@@ -2697,7 +2697,7 @@ async def confirm_daily_tariff_switch(
|
||||
squads = [s.squad_uuid for s in all_servers if s.squad_uuid]
|
||||
|
||||
# Обновляем подписку на суточный тариф
|
||||
# Сохраняем докупленные устройства при смене тарифа
|
||||
# Сбрасываем лимит устройств на базу нового тарифа (докупленные не переносятся)
|
||||
from app.database.crud.subscription import calc_device_limit_on_tariff_switch
|
||||
|
||||
old_tariff = await get_tariff_by_id(db, subscription.tariff_id) if subscription.tariff_id else None
|
||||
@@ -3288,7 +3288,7 @@ async def confirm_instant_switch(
|
||||
is_new_daily = getattr(new_tariff, 'is_daily', False)
|
||||
|
||||
# Обновляем подписку с новыми параметрами тарифа
|
||||
# Сохраняем докупленные устройства при смене тарифа
|
||||
# Сбрасываем лимит устройств на базу нового тарифа (докупленные не переносятся)
|
||||
from app.database.crud.subscription import calc_device_limit_on_tariff_switch
|
||||
|
||||
old_tariff = await get_tariff_by_id(db, subscription.tariff_id) if subscription.tariff_id else None
|
||||
|
||||
@@ -219,7 +219,10 @@ async def send_error_to_admin_chat(
|
||||
global _last_error_notification
|
||||
|
||||
chat_id = getattr(settings, 'ADMIN_NOTIFICATIONS_CHAT_ID', None)
|
||||
topic_id = getattr(settings, 'ADMIN_NOTIFICATIONS_TOPIC_ID', None)
|
||||
# Используем топик для ошибок, если настроен, иначе общий
|
||||
topic_id = getattr(settings, 'ADMIN_NOTIFICATIONS_ERRORS_TOPIC_ID', None) or getattr(
|
||||
settings, 'ADMIN_NOTIFICATIONS_TOPIC_ID', None
|
||||
)
|
||||
enabled = getattr(settings, 'ADMIN_NOTIFICATIONS_ENABLED', False)
|
||||
|
||||
if not enabled or not chat_id:
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import html
|
||||
from datetime import UTC, datetime
|
||||
from enum import StrEnum
|
||||
from typing import Any
|
||||
|
||||
import structlog
|
||||
@@ -26,6 +27,21 @@ from app.utils.message_patch import caption_exceeds_telegram_limit
|
||||
from app.utils.timezone import format_local_datetime
|
||||
|
||||
|
||||
class NotificationCategory(StrEnum):
|
||||
"""Категории уведомлений для маршрутизации по топикам."""
|
||||
|
||||
PURCHASES = 'purchases' # Покупки подписок, покупки с лендинга
|
||||
RENEWALS = 'renewals' # Продления
|
||||
TRIALS = 'trials' # Триалы
|
||||
BALANCE = 'balance' # Пополнение баланса
|
||||
ADDONS = 'addons' # Докупка трафика/устройств/серверов
|
||||
INFRASTRUCTURE = 'infrastructure' # Ноды, техработы, статус панели, вебхуки
|
||||
ERRORS = 'errors' # Ошибки бота, краши
|
||||
PROMO = 'promo' # Промокоды, кампании, промогруппы
|
||||
PARTNERS = 'partners' # Партнёрки, выводы, админ-действия
|
||||
TICKETS = 'tickets' # Тикеты (уже существует)
|
||||
|
||||
|
||||
logger = structlog.get_logger(__name__)
|
||||
|
||||
|
||||
@@ -37,6 +53,20 @@ class AdminNotificationService:
|
||||
self.ticket_topic_id = getattr(settings, 'ADMIN_NOTIFICATIONS_TICKET_TOPIC_ID', None)
|
||||
self.enabled = getattr(settings, 'ADMIN_NOTIFICATIONS_ENABLED', False)
|
||||
|
||||
# Маппинг категорий на topic_id (None = fallback на self.topic_id)
|
||||
self.category_topics: dict[NotificationCategory, int | None] = {
|
||||
NotificationCategory.PURCHASES: getattr(settings, 'ADMIN_NOTIFICATIONS_PURCHASES_TOPIC_ID', None),
|
||||
NotificationCategory.RENEWALS: getattr(settings, 'ADMIN_NOTIFICATIONS_RENEWALS_TOPIC_ID', None),
|
||||
NotificationCategory.TRIALS: getattr(settings, 'ADMIN_NOTIFICATIONS_TRIALS_TOPIC_ID', None),
|
||||
NotificationCategory.BALANCE: getattr(settings, 'ADMIN_NOTIFICATIONS_BALANCE_TOPIC_ID', None),
|
||||
NotificationCategory.ADDONS: getattr(settings, 'ADMIN_NOTIFICATIONS_ADDONS_TOPIC_ID', None),
|
||||
NotificationCategory.INFRASTRUCTURE: getattr(settings, 'ADMIN_NOTIFICATIONS_INFRASTRUCTURE_TOPIC_ID', None),
|
||||
NotificationCategory.ERRORS: getattr(settings, 'ADMIN_NOTIFICATIONS_ERRORS_TOPIC_ID', None),
|
||||
NotificationCategory.PROMO: getattr(settings, 'ADMIN_NOTIFICATIONS_PROMO_TOPIC_ID', None),
|
||||
NotificationCategory.PARTNERS: getattr(settings, 'ADMIN_NOTIFICATIONS_PARTNERS_TOPIC_ID', None),
|
||||
NotificationCategory.TICKETS: self.ticket_topic_id,
|
||||
}
|
||||
|
||||
async def _get_referrer_info(self, db: AsyncSession, referred_by_id: int | None) -> str:
|
||||
if not referred_by_id:
|
||||
return 'Нет'
|
||||
@@ -371,7 +401,7 @@ class AdminNotificationService:
|
||||
message_lines.append('')
|
||||
message_lines.append(f'⏰ <i>{format_local_datetime(datetime.now(UTC), "%d.%m.%Y %H:%M:%S")}</i>')
|
||||
|
||||
return await self._send_message('\n'.join(message_lines))
|
||||
return await self._send_message('\n'.join(message_lines), category=NotificationCategory.TRIALS)
|
||||
|
||||
except Exception as e:
|
||||
logger.error('Ошибка отправки уведомления о триале', error=e)
|
||||
@@ -502,7 +532,15 @@ class AdminNotificationService:
|
||||
]
|
||||
)
|
||||
|
||||
return await self._send_message('\n'.join(message_lines))
|
||||
# Маршрутизация по категориям (зеркалит логику заголовков выше)
|
||||
if purchase_type == 'renewal' or (
|
||||
not was_trial_conversion and purchase_type is None and user.has_had_paid_subscription
|
||||
):
|
||||
cat = NotificationCategory.RENEWALS
|
||||
else:
|
||||
cat = NotificationCategory.PURCHASES
|
||||
|
||||
return await self._send_message('\n'.join(message_lines), category=cat)
|
||||
|
||||
except Exception as e:
|
||||
logger.error('Ошибка отправки уведомления о покупке', error=e)
|
||||
@@ -565,7 +603,7 @@ class AdminNotificationService:
|
||||
else:
|
||||
message = f'{message_prefix}{message_suffix}'
|
||||
|
||||
return await self._send_message(message)
|
||||
return await self._send_message(message, category=NotificationCategory.INFRASTRUCTURE)
|
||||
|
||||
except Exception as e:
|
||||
logger.error('Ошибка отправки уведомления об обновлении', error=e)
|
||||
@@ -586,7 +624,7 @@ class AdminNotificationService:
|
||||
|
||||
⚙️ <i>Система автоматических обновлений • {format_local_datetime(datetime.now(UTC), '%d.%m.%Y %H:%M:%S')}</i>"""
|
||||
|
||||
return await self._send_message(message)
|
||||
return await self._send_message(message, category=NotificationCategory.ERRORS)
|
||||
|
||||
except Exception as e:
|
||||
logger.error('Ошибка отправки уведомления об ошибке проверки версий', error=e)
|
||||
@@ -824,7 +862,7 @@ class AdminNotificationService:
|
||||
return False
|
||||
|
||||
try:
|
||||
return await self._send_message(message)
|
||||
return await self._send_message(message, category=NotificationCategory.BALANCE)
|
||||
except Exception as e:
|
||||
logger.error('Ошибка отправки уведомления о пополнении', error=e, exc_info=True)
|
||||
return False
|
||||
@@ -901,7 +939,7 @@ class AdminNotificationService:
|
||||
|
||||
⏰ <i>{format_local_datetime(datetime.now(UTC), '%d.%m.%Y %H:%M:%S')}</i>"""
|
||||
|
||||
return await self._send_message(message)
|
||||
return await self._send_message(message, category=NotificationCategory.RENEWALS)
|
||||
|
||||
except Exception as e:
|
||||
logger.error('Ошибка отправки уведомления о продлении', error=e)
|
||||
@@ -1008,7 +1046,7 @@ class AdminNotificationService:
|
||||
]
|
||||
)
|
||||
|
||||
return await self._send_message('\n'.join(message_lines))
|
||||
return await self._send_message('\n'.join(message_lines), category=NotificationCategory.PROMO)
|
||||
|
||||
except Exception as e:
|
||||
logger.error('Ошибка отправки уведомления об активации промокода', error=e)
|
||||
@@ -1097,7 +1135,7 @@ class AdminNotificationService:
|
||||
]
|
||||
)
|
||||
|
||||
return await self._send_message('\n'.join(message_lines))
|
||||
return await self._send_message('\n'.join(message_lines), category=NotificationCategory.PROMO)
|
||||
|
||||
except Exception as e:
|
||||
logger.error('Ошибка отправки уведомления о переходе по кампании', error=e)
|
||||
@@ -1187,14 +1225,30 @@ class AdminNotificationService:
|
||||
]
|
||||
)
|
||||
|
||||
return await self._send_message('\n'.join(message_lines))
|
||||
return await self._send_message('\n'.join(message_lines), category=NotificationCategory.PROMO)
|
||||
|
||||
except Exception as e:
|
||||
logger.error('Ошибка отправки уведомления о смене промогруппы', error=e)
|
||||
return False
|
||||
|
||||
def _resolve_topic_id(self, category: NotificationCategory | None = None) -> int | None:
|
||||
"""Определяет topic_id для сообщения.
|
||||
|
||||
Если указана category и для неё настроен топик — возвращает его.
|
||||
Иначе — fallback на self.topic_id (общий топик).
|
||||
"""
|
||||
if category:
|
||||
topic = self.category_topics.get(category)
|
||||
if topic is not None:
|
||||
return topic
|
||||
return self.topic_id
|
||||
|
||||
async def _send_message(
|
||||
self, text: str, reply_markup: types.InlineKeyboardMarkup | None = None, *, ticket_event: bool = False
|
||||
self,
|
||||
text: str,
|
||||
reply_markup: types.InlineKeyboardMarkup | None = None,
|
||||
*,
|
||||
category: NotificationCategory | None = None,
|
||||
) -> bool:
|
||||
if not self.chat_id:
|
||||
logger.warning('ADMIN_NOTIFICATIONS_CHAT_ID не настроен')
|
||||
@@ -1208,19 +1262,14 @@ class AdminNotificationService:
|
||||
'disable_web_page_preview': True,
|
||||
}
|
||||
|
||||
# route to ticket-specific topic if provided
|
||||
thread_id = None
|
||||
if ticket_event and self.ticket_topic_id:
|
||||
thread_id = self.ticket_topic_id
|
||||
elif self.topic_id:
|
||||
thread_id = self.topic_id
|
||||
thread_id = self._resolve_topic_id(category)
|
||||
if thread_id:
|
||||
message_kwargs['message_thread_id'] = thread_id
|
||||
if reply_markup is not None:
|
||||
message_kwargs['reply_markup'] = reply_markup
|
||||
|
||||
await self.bot.send_message(**message_kwargs)
|
||||
logger.info('Уведомление отправлено в чат', chat_id=self.chat_id)
|
||||
logger.info('Уведомление отправлено в чат', chat_id=self.chat_id, category=category)
|
||||
return True
|
||||
|
||||
except TelegramForbiddenError:
|
||||
@@ -1241,11 +1290,17 @@ class AdminNotificationService:
|
||||
"""Public check for whether admin notifications are configured and active."""
|
||||
return self._is_enabled()
|
||||
|
||||
async def send_admin_notification(self, text: str, reply_markup: types.InlineKeyboardMarkup | None = None) -> bool:
|
||||
async def send_admin_notification(
|
||||
self,
|
||||
text: str,
|
||||
reply_markup: types.InlineKeyboardMarkup | None = None,
|
||||
*,
|
||||
category: NotificationCategory | None = None,
|
||||
) -> bool:
|
||||
"""Send a generic notification to admin chat with optional inline keyboard."""
|
||||
if not self._is_enabled():
|
||||
return False
|
||||
return await self._send_message(text, reply_markup=reply_markup)
|
||||
return await self._send_message(text, reply_markup=reply_markup, category=category)
|
||||
|
||||
async def send_guest_purchase_notification(
|
||||
self,
|
||||
@@ -1316,7 +1371,7 @@ class AdminNotificationService:
|
||||
|
||||
message_lines.append(f'<i>{format_local_datetime(datetime.now(UTC), "%d.%m.%Y %H:%M")}</i>')
|
||||
|
||||
return await self._send_message('\n'.join(message_lines))
|
||||
return await self._send_message('\n'.join(message_lines), category=NotificationCategory.PURCHASES)
|
||||
|
||||
except Exception as e:
|
||||
logger.error('Ошибка отправки уведомления о гостевой покупке', error=e)
|
||||
@@ -1330,7 +1385,7 @@ class AdminNotificationService:
|
||||
"""
|
||||
if not self._is_enabled():
|
||||
return False
|
||||
return await self._send_message(text)
|
||||
return await self._send_message(text, category=NotificationCategory.INFRASTRUCTURE)
|
||||
|
||||
def _get_payment_method_display(self, payment_method: str | None) -> str:
|
||||
if not payment_method:
|
||||
@@ -1516,7 +1571,7 @@ class AdminNotificationService:
|
||||
|
||||
message = '\n'.join(message_parts)
|
||||
|
||||
return await self._send_message(message)
|
||||
return await self._send_message(message, category=NotificationCategory.INFRASTRUCTURE)
|
||||
|
||||
except Exception as e:
|
||||
logger.error('Ошибка отправки уведомления о техработах', error=e)
|
||||
@@ -1601,7 +1656,7 @@ class AdminNotificationService:
|
||||
|
||||
message = '\n'.join(message_parts)
|
||||
|
||||
return await self._send_message(message)
|
||||
return await self._send_message(message, category=NotificationCategory.INFRASTRUCTURE)
|
||||
|
||||
except Exception as e:
|
||||
logger.error('Ошибка отправки уведомления о статусе панели Remnawave', error=e)
|
||||
@@ -1694,7 +1749,7 @@ class AdminNotificationService:
|
||||
]
|
||||
)
|
||||
|
||||
return await self._send_message('\n'.join(message_lines))
|
||||
return await self._send_message('\n'.join(message_lines), category=NotificationCategory.ADDONS)
|
||||
|
||||
except Exception as e:
|
||||
logger.error('Ошибка отправки уведомления об изменении подписки', error=e)
|
||||
@@ -1778,7 +1833,7 @@ class AdminNotificationService:
|
||||
]
|
||||
)
|
||||
|
||||
return await self._send_message('\n'.join(message_lines))
|
||||
return await self._send_message('\n'.join(message_lines), category=NotificationCategory.PARTNERS)
|
||||
|
||||
except Exception as e:
|
||||
logger.error('Ошибка отправки уведомления о заявке на партнёрку', error=e)
|
||||
@@ -1829,7 +1884,7 @@ class AdminNotificationService:
|
||||
]
|
||||
)
|
||||
|
||||
return await self._send_message('\n'.join(message_lines))
|
||||
return await self._send_message('\n'.join(message_lines), category=NotificationCategory.PARTNERS)
|
||||
|
||||
except Exception as e:
|
||||
logger.error('Ошибка отправки уведомления о запросе на вывод', error=e)
|
||||
@@ -1873,7 +1928,7 @@ class AdminNotificationService:
|
||||
)
|
||||
|
||||
message = '\n'.join(message_lines)
|
||||
return await self._send_message(message)
|
||||
return await self._send_message(message, category=NotificationCategory.PARTNERS)
|
||||
|
||||
except Exception as e:
|
||||
logger.error('Ошибка отправки уведомления о массовой блокировке', error=e)
|
||||
@@ -1910,7 +1965,7 @@ class AdminNotificationService:
|
||||
if media_file_id and media_type == 'photo':
|
||||
return await self._send_ticket_photo_notification(text, media_file_id, keyboard)
|
||||
|
||||
return await self._send_message(text, reply_markup=keyboard, ticket_event=True)
|
||||
return await self._send_message(text, reply_markup=keyboard, category=NotificationCategory.TICKETS)
|
||||
|
||||
async def _send_ticket_photo_notification(
|
||||
self,
|
||||
@@ -1925,7 +1980,7 @@ class AdminNotificationService:
|
||||
if not self.chat_id:
|
||||
return False
|
||||
|
||||
thread_id = self.ticket_topic_id or self.topic_id
|
||||
thread_id = self._resolve_topic_id(category=NotificationCategory.TICKETS)
|
||||
|
||||
try:
|
||||
if not caption_exceeds_telegram_limit(text):
|
||||
@@ -1943,7 +1998,7 @@ class AdminNotificationService:
|
||||
await self.bot.send_photo(**photo_kwargs)
|
||||
else:
|
||||
# Текст отдельно, фото следом в тот же топик
|
||||
await self._send_message(text, reply_markup=keyboard, ticket_event=True)
|
||||
await self._send_message(text, reply_markup=keyboard, category=NotificationCategory.TICKETS)
|
||||
photo_kwargs = {
|
||||
'chat_id': self.chat_id,
|
||||
'photo': photo_file_id,
|
||||
@@ -1956,7 +2011,7 @@ class AdminNotificationService:
|
||||
except Exception as e:
|
||||
logger.error('Ошибка отправки фото-уведомления тикета', error=e)
|
||||
# Fallback: отправляем хотя бы текст
|
||||
return await self._send_message(text, reply_markup=keyboard, ticket_event=True)
|
||||
return await self._send_message(text, reply_markup=keyboard, category=NotificationCategory.TICKETS)
|
||||
|
||||
async def send_suspicious_traffic_notification(self, message: str, bot: Bot, topic_id: int | None = None) -> bool:
|
||||
"""
|
||||
|
||||
@@ -1810,10 +1810,12 @@ class BackupService:
|
||||
notification_text += f'\n\n⏰ <i>{datetime.now(UTC).strftime("%d.%m.%Y %H:%M:%S")}</i>'
|
||||
|
||||
try:
|
||||
from app.services.admin_notification_service import AdminNotificationService
|
||||
from app.services.admin_notification_service import AdminNotificationService, NotificationCategory
|
||||
|
||||
admin_service = AdminNotificationService(self.bot)
|
||||
await admin_service._send_message(notification_text)
|
||||
await admin_service.send_admin_notification(
|
||||
notification_text, category=NotificationCategory.INFRASTRUCTURE
|
||||
)
|
||||
except Exception as e:
|
||||
logger.error('Ошибка отправки уведомления через AdminNotificationService', error=e)
|
||||
|
||||
|
||||
@@ -7,7 +7,7 @@ from datetime import UTC, datetime, timedelta
|
||||
from typing import Literal
|
||||
|
||||
import structlog
|
||||
from sqlalchemy import func, or_, select
|
||||
from sqlalchemy import func, or_, select, update
|
||||
from sqlalchemy.exc import IntegrityError
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
@@ -392,24 +392,41 @@ async def fulfill_purchase(
|
||||
return purchase
|
||||
|
||||
|
||||
def _resolve_base_payment_method(method_str: str | None) -> str:
|
||||
"""Resolve base payment method string by stripping sub-option suffixes.
|
||||
|
||||
'yookassa_sbp' → 'yookassa', 'kassa_ai' → 'kassa_ai' (enum match keeps it),
|
||||
'platega_2' → 'platega'.
|
||||
"""
|
||||
if not method_str:
|
||||
return ''
|
||||
# If exact enum match, return as-is (handles 'telegram_stars', 'kassa_ai', etc.)
|
||||
try:
|
||||
PaymentMethod(method_str)
|
||||
return method_str
|
||||
except ValueError:
|
||||
pass
|
||||
# Strip sub-option suffix
|
||||
if '_' in method_str:
|
||||
base = method_str.rsplit('_', 1)[0]
|
||||
try:
|
||||
PaymentMethod(base)
|
||||
return base
|
||||
except ValueError:
|
||||
pass
|
||||
return method_str
|
||||
|
||||
|
||||
def _resolve_payment_method(method_str: str | None) -> PaymentMethod | None:
|
||||
"""Convert payment method string from GuestPurchase to PaymentMethod enum."""
|
||||
if not method_str:
|
||||
return None
|
||||
# Try exact match first (handles 'telegram_stars', 'kassa_ai', 'yookassa', etc.)
|
||||
base = _resolve_base_payment_method(method_str)
|
||||
try:
|
||||
return PaymentMethod(method_str)
|
||||
return PaymentMethod(base)
|
||||
except ValueError:
|
||||
pass
|
||||
# Strip sub-option suffix ('yookassa_sbp' → 'yookassa', 'platega_2' → 'platega')
|
||||
if '_' in method_str:
|
||||
base_method = method_str.split('_')[0]
|
||||
try:
|
||||
return PaymentMethod(base_method)
|
||||
except ValueError:
|
||||
pass
|
||||
logger.debug('Unknown payment method for transaction', method=method_str)
|
||||
return None
|
||||
logger.debug('Unknown payment method for transaction', method=method_str)
|
||||
return None
|
||||
|
||||
|
||||
def _mask_email(email: str) -> str:
|
||||
@@ -1002,14 +1019,16 @@ async def retry_stuck_paid_purchases(
|
||||
stale_minutes: int = 5,
|
||||
limit: int = 10,
|
||||
max_age_hours: int = 24,
|
||||
max_retries: int = 20,
|
||||
) -> int:
|
||||
"""Retry fulfillment for purchases stuck in PAID status.
|
||||
|
||||
Finds purchases that have been in PAID status for longer than stale_minutes
|
||||
(but not older than max_age_hours) and attempts to fulfill them in isolated
|
||||
sessions. Returns the number of successfully retried purchases.
|
||||
(but not older than max_age_hours, and with retry_count < max_retries) and
|
||||
attempts to fulfill them in isolated sessions.
|
||||
|
||||
Purchases older than max_age_hours are left for manual investigation.
|
||||
Purchases exceeding max_retries are marked FAILED and an admin alert is sent.
|
||||
Returns the number of successfully retried purchases.
|
||||
"""
|
||||
from app.database.database import AsyncSessionLocal
|
||||
|
||||
@@ -1018,10 +1037,12 @@ async def retry_stuck_paid_purchases(
|
||||
|
||||
# Collect tokens only — each retry gets its own session.
|
||||
# NULL paid_at is included via or_() as a safety net for data anomalies.
|
||||
# Filter retry_count < max_retries in SQL to avoid wasting LIMIT slots.
|
||||
result = await db.execute(
|
||||
select(GuestPurchase.token)
|
||||
.where(
|
||||
GuestPurchase.status == GuestPurchaseStatus.PAID.value,
|
||||
GuestPurchase.retry_count < max_retries,
|
||||
or_(GuestPurchase.paid_at < cutoff, GuestPurchase.paid_at.is_(None)),
|
||||
or_(GuestPurchase.paid_at > max_age, GuestPurchase.paid_at.is_(None)),
|
||||
# Exclude code-only gifts — they stay PAID intentionally until activated
|
||||
@@ -1032,6 +1053,9 @@ async def retry_stuck_paid_purchases(
|
||||
)
|
||||
tokens = result.scalars().all()
|
||||
|
||||
# Separately fail exhausted purchases (retry_count >= max_retries)
|
||||
await _fail_exhausted_purchases_batch(db, GuestPurchaseStatus.PAID, max_retries, max_age)
|
||||
|
||||
if not tokens:
|
||||
return 0
|
||||
|
||||
@@ -1039,6 +1063,7 @@ async def retry_stuck_paid_purchases(
|
||||
for token in tokens:
|
||||
try:
|
||||
async with AsyncSessionLocal() as retry_db:
|
||||
await _increment_retry_count(retry_db, token)
|
||||
await fulfill_purchase(retry_db, token)
|
||||
retried += 1
|
||||
logger.info('Retried stuck purchase successfully', token_prefix=token[:5])
|
||||
@@ -1053,12 +1078,15 @@ async def retry_stuck_pending_activation(
|
||||
stale_minutes: int = 10,
|
||||
limit: int = 10,
|
||||
max_age_hours: int = 24,
|
||||
max_retries: int = 20,
|
||||
) -> int:
|
||||
"""Retry activation for purchases stuck in PENDING_ACTIVATION status.
|
||||
|
||||
This handles the case where activate_purchase() failed after the status
|
||||
was already transitioned to PENDING_ACTIVATION (e.g., Remnawave panel was
|
||||
temporarily down). Each retry runs in an isolated session.
|
||||
|
||||
Purchases exceeding max_retries are marked FAILED and an admin alert is sent.
|
||||
"""
|
||||
from app.database.database import AsyncSessionLocal
|
||||
|
||||
@@ -1069,6 +1097,7 @@ async def retry_stuck_pending_activation(
|
||||
select(GuestPurchase.token)
|
||||
.where(
|
||||
GuestPurchase.status == GuestPurchaseStatus.PENDING_ACTIVATION.value,
|
||||
GuestPurchase.retry_count < max_retries,
|
||||
or_(GuestPurchase.paid_at < cutoff, GuestPurchase.paid_at.is_(None)),
|
||||
or_(GuestPurchase.paid_at > max_age, GuestPurchase.paid_at.is_(None)),
|
||||
GuestPurchase.user_id.isnot(None),
|
||||
@@ -1078,6 +1107,9 @@ async def retry_stuck_pending_activation(
|
||||
)
|
||||
tokens = result.scalars().all()
|
||||
|
||||
# Separately fail exhausted purchases (retry_count >= max_retries)
|
||||
await _fail_exhausted_purchases_batch(db, GuestPurchaseStatus.PENDING_ACTIVATION, max_retries, max_age)
|
||||
|
||||
if not tokens:
|
||||
return 0
|
||||
|
||||
@@ -1085,6 +1117,7 @@ async def retry_stuck_pending_activation(
|
||||
for token in tokens:
|
||||
try:
|
||||
async with AsyncSessionLocal() as retry_db:
|
||||
await _increment_retry_count(retry_db, token)
|
||||
await activate_purchase(retry_db, token)
|
||||
retried += 1
|
||||
logger.info('Retried stuck pending_activation successfully', token_prefix=token[:5])
|
||||
@@ -1092,3 +1125,369 @@ async def retry_stuck_pending_activation(
|
||||
logger.exception('Failed to retry stuck pending_activation', token_prefix=token[:5])
|
||||
|
||||
return retried
|
||||
|
||||
|
||||
async def _increment_retry_count(db: AsyncSession, purchase_token: str) -> None:
|
||||
"""Atomically increment retry_count via UPDATE statement (no SELECT, no identity map pollution)."""
|
||||
await db.execute(
|
||||
update(GuestPurchase)
|
||||
.where(GuestPurchase.token == purchase_token)
|
||||
.values(retry_count=GuestPurchase.retry_count + 1)
|
||||
)
|
||||
await db.commit()
|
||||
|
||||
|
||||
async def _fail_exhausted_purchases_batch(
|
||||
db: AsyncSession,
|
||||
status: GuestPurchaseStatus,
|
||||
max_retries: int,
|
||||
max_age: datetime,
|
||||
) -> None:
|
||||
"""Find and mark exhausted purchases as FAILED, then send admin alerts."""
|
||||
from app.database.crud.landing import update_purchase_status
|
||||
from app.database.database import AsyncSessionLocal
|
||||
|
||||
result = await db.execute(
|
||||
select(GuestPurchase.token, GuestPurchase.retry_count)
|
||||
.where(
|
||||
GuestPurchase.status == status.value,
|
||||
GuestPurchase.retry_count >= max_retries,
|
||||
or_(GuestPurchase.paid_at > max_age, GuestPurchase.paid_at.is_(None)),
|
||||
)
|
||||
.limit(10)
|
||||
)
|
||||
exhausted = result.all()
|
||||
|
||||
for token, retry_count in exhausted:
|
||||
# Collect alert data before closing the session
|
||||
alert_data: dict | None = None
|
||||
try:
|
||||
async with AsyncSessionLocal() as fail_db:
|
||||
row = await fail_db.execute(select(GuestPurchase).where(GuestPurchase.token == token).with_for_update())
|
||||
purchase = row.scalars().first()
|
||||
if purchase and purchase.status not in (
|
||||
GuestPurchaseStatus.DELIVERED.value,
|
||||
GuestPurchaseStatus.FAILED.value,
|
||||
):
|
||||
# Capture alert data before commit expires attributes
|
||||
alert_data = {
|
||||
'id': purchase.id,
|
||||
'token': purchase.token,
|
||||
'amount_kopeks': purchase.amount_kopeks,
|
||||
'payment_method': purchase.payment_method,
|
||||
'payment_id': purchase.payment_id,
|
||||
'contact_type': purchase.contact_type,
|
||||
'contact_value': purchase.contact_value,
|
||||
'created_at': purchase.created_at,
|
||||
}
|
||||
await update_purchase_status(fail_db, token, GuestPurchaseStatus.FAILED)
|
||||
logger.error(
|
||||
'Purchase exceeded max retries — marked FAILED',
|
||||
token_prefix=token[:5],
|
||||
retry_count=retry_count,
|
||||
phase=status.value,
|
||||
)
|
||||
except Exception:
|
||||
logger.exception('Failed to mark exhausted purchase as FAILED', token_prefix=token[:5])
|
||||
|
||||
# Send alert OUTSIDE the session (no row lock held)
|
||||
if alert_data:
|
||||
await _send_stuck_purchase_alert(alert_data, retry_count, status.value)
|
||||
|
||||
|
||||
async def _send_stuck_purchase_alert(data: dict, retry_count: int, phase: str) -> None:
|
||||
"""Send admin notification about a purchase that exhausted all retries.
|
||||
|
||||
Accepts a plain dict (not ORM object) so it can be called after the session is closed.
|
||||
"""
|
||||
if not getattr(settings, 'ADMIN_NOTIFICATIONS_ENABLED', False) or not settings.BOT_TOKEN:
|
||||
return
|
||||
try:
|
||||
import html as html_mod
|
||||
|
||||
from aiogram import Bot
|
||||
|
||||
from app.services.admin_notification_service import AdminNotificationService, NotificationCategory
|
||||
|
||||
amount_rub = data['amount_kopeks'] / 100
|
||||
contact_value = html_mod.escape(str(data.get('contact_value', '?')))
|
||||
contact_type = html_mod.escape(str(data.get('contact_type', '?')))
|
||||
text = (
|
||||
f'<b>STUCK PURCHASE — retries exhausted</b>\n\n'
|
||||
f'Token: <code>{data["token"][:8]}...</code>\n'
|
||||
f'Status: <code>{phase}</code> → <code>FAILED</code>\n'
|
||||
f'Retries: <b>{retry_count}</b>\n'
|
||||
f'Amount: <b>{amount_rub:.0f} ₽</b>\n'
|
||||
f'Payment: <code>{html_mod.escape(str(data.get("payment_method") or "?"))}</code>\n'
|
||||
f'Payment ID: <code>{html_mod.escape(str(data.get("payment_id") or "?"))}</code>\n'
|
||||
f'Contact: {contact_type}: <code>{contact_value}</code>\n'
|
||||
f'Created: {data["created_at"]:%Y-%m-%d %H:%M UTC}\n\n'
|
||||
f'Requires manual investigation.'
|
||||
)
|
||||
|
||||
async with Bot(token=settings.BOT_TOKEN) as bot:
|
||||
service = AdminNotificationService(bot)
|
||||
await service.send_admin_notification(text, category=NotificationCategory.ERRORS)
|
||||
except Exception:
|
||||
logger.warning('Failed to send stuck purchase admin alert', purchase_id=data.get('id'), exc_info=True)
|
||||
|
||||
|
||||
async def _send_amount_mismatch_alert(
|
||||
purchase: GuestPurchase,
|
||||
provider_amount_kopeks: int,
|
||||
provider_payment_id: str,
|
||||
payment_method: str | None,
|
||||
) -> None:
|
||||
"""Send admin alert when recovery detects an amount mismatch (possible fraud or bug)."""
|
||||
if not getattr(settings, 'ADMIN_NOTIFICATIONS_ENABLED', False) or not settings.BOT_TOKEN:
|
||||
return
|
||||
try:
|
||||
import html as html_mod
|
||||
|
||||
from aiogram import Bot
|
||||
|
||||
from app.services.admin_notification_service import AdminNotificationService, NotificationCategory
|
||||
|
||||
text = (
|
||||
f'<b>AMOUNT MISMATCH — purchase marked FAILED</b>\n\n'
|
||||
f'Token: <code>{purchase.token[:8]}...</code>\n'
|
||||
f'Expected: <b>{purchase.amount_kopeks / 100:.0f} ₽</b>\n'
|
||||
f'Provider: <b>{provider_amount_kopeks / 100:.0f} ₽</b>\n'
|
||||
f'Payment: <code>{html_mod.escape(str(payment_method or "?"))}</code>\n'
|
||||
f'Payment ID: <code>{html_mod.escape(str(provider_payment_id))}</code>\n'
|
||||
f'Contact: {html_mod.escape(str(purchase.contact_type))}: '
|
||||
f'<code>{html_mod.escape(str(purchase.contact_value))}</code>\n\n'
|
||||
f'Requires manual investigation.'
|
||||
)
|
||||
|
||||
async with Bot(token=settings.BOT_TOKEN) as bot:
|
||||
service = AdminNotificationService(bot)
|
||||
await service.send_admin_notification(text, category=NotificationCategory.ERRORS)
|
||||
except Exception:
|
||||
logger.warning('Failed to send amount mismatch alert', purchase_id=purchase.id, exc_info=True)
|
||||
|
||||
|
||||
async def recover_stuck_pending_purchases(
|
||||
db: AsyncSession,
|
||||
stale_minutes: int = 10,
|
||||
limit: int = 10,
|
||||
max_age_hours: int = 24,
|
||||
) -> int:
|
||||
"""Recover purchases stuck in PENDING by checking provider payment status.
|
||||
|
||||
Queries all payment provider tables (YooKassa, Heleket, CryptoBot, etc.)
|
||||
for succeeded payments matching the purchase_token. If a provider payment
|
||||
is confirmed but the GuestPurchase is still PENDING (webhook was lost or
|
||||
processing failed), marks the purchase as PAID so retry_stuck_paid_purchases
|
||||
can fulfill it. Includes amount verification.
|
||||
|
||||
Returns the number of recovered purchases.
|
||||
"""
|
||||
from app.database.database import AsyncSessionLocal
|
||||
|
||||
cutoff = datetime.now(UTC) - timedelta(minutes=stale_minutes)
|
||||
max_age = datetime.now(UTC) - timedelta(hours=max_age_hours)
|
||||
|
||||
# Find PENDING purchases older than stale_minutes but younger than max_age_hours
|
||||
result = await db.execute(
|
||||
select(GuestPurchase.token, GuestPurchase.payment_method)
|
||||
.where(
|
||||
GuestPurchase.status == GuestPurchaseStatus.PENDING.value,
|
||||
GuestPurchase.created_at < cutoff,
|
||||
GuestPurchase.created_at > max_age,
|
||||
)
|
||||
.order_by(GuestPurchase.created_at.asc())
|
||||
.limit(limit)
|
||||
)
|
||||
pending_purchases = result.all()
|
||||
|
||||
if not pending_purchases:
|
||||
return 0
|
||||
|
||||
recovered = 0
|
||||
for token, payment_method in pending_purchases:
|
||||
try:
|
||||
async with AsyncSessionLocal() as recover_db:
|
||||
paid = await _check_and_recover_pending_purchase(recover_db, token, payment_method)
|
||||
if paid:
|
||||
recovered += 1
|
||||
except Exception:
|
||||
logger.exception('Failed to recover pending purchase', token_prefix=token[:5])
|
||||
|
||||
return recovered
|
||||
|
||||
|
||||
async def _find_succeeded_provider_payment(
|
||||
db: AsyncSession,
|
||||
base_method: str,
|
||||
purchase_token: str,
|
||||
) -> tuple[str, int | None] | None:
|
||||
"""Query provider payment tables for a succeeded payment matching purchase_token.
|
||||
|
||||
Returns ``(provider_payment_id, amount_kopeks)`` or ``None``.
|
||||
``amount_kopeks`` is ``None`` when the amount check should be skipped
|
||||
(e.g., CryptoBot where USD→RUB conversion introduces imprecision).
|
||||
"""
|
||||
from sqlalchemy import cast
|
||||
from sqlalchemy.types import JSON as SA_JSON
|
||||
|
||||
from app.database.models import (
|
||||
CloudPaymentsPayment,
|
||||
CryptoBotPayment,
|
||||
FreekassaPayment,
|
||||
HeleketPayment,
|
||||
KassaAiPayment,
|
||||
MulenPayPayment,
|
||||
Pal24Payment,
|
||||
PlategaPayment,
|
||||
RioPayPayment,
|
||||
SeverPayPayment,
|
||||
WataPayment,
|
||||
YooKassaPayment,
|
||||
)
|
||||
|
||||
# --- CryptoBot: special case — payload field (text JSON), skip amount check ---
|
||||
if base_method == 'cryptobot':
|
||||
result = await db.execute(
|
||||
select(CryptoBotPayment).where(
|
||||
CryptoBotPayment.status == 'paid',
|
||||
cast(CryptoBotPayment.payload, SA_JSON)['purchase_token'].as_string() == purchase_token,
|
||||
)
|
||||
)
|
||||
p = result.scalars().first()
|
||||
return (p.invoice_id, None) if p else None
|
||||
|
||||
# --- All other providers: metadata_json['purchase_token'] + is_paid/status filters ---
|
||||
model = None
|
||||
payment_id_attr: str = ''
|
||||
extra_conditions: list = []
|
||||
|
||||
if base_method.startswith('yookassa'):
|
||||
model = YooKassaPayment
|
||||
payment_id_attr = 'yookassa_payment_id'
|
||||
extra_conditions = [YooKassaPayment.status == 'succeeded', YooKassaPayment.is_paid.is_(True)]
|
||||
elif base_method == 'heleket':
|
||||
model = HeleketPayment
|
||||
payment_id_attr = 'uuid'
|
||||
extra_conditions = [HeleketPayment.status.in_(['paid', 'paid_over'])]
|
||||
elif base_method == 'mulenpay':
|
||||
model = MulenPayPayment
|
||||
payment_id_attr = 'uuid'
|
||||
extra_conditions = [MulenPayPayment.is_paid.is_(True)]
|
||||
elif base_method == 'pal24':
|
||||
model = Pal24Payment
|
||||
payment_id_attr = 'bill_id'
|
||||
extra_conditions = [Pal24Payment.is_paid.is_(True)]
|
||||
elif base_method == 'wata':
|
||||
model = WataPayment
|
||||
payment_id_attr = 'payment_link_id'
|
||||
extra_conditions = [WataPayment.is_paid.is_(True)]
|
||||
elif base_method == 'platega':
|
||||
model = PlategaPayment
|
||||
payment_id_attr = 'platega_transaction_id'
|
||||
extra_conditions = [PlategaPayment.is_paid.is_(True)]
|
||||
elif base_method == 'cloudpayments':
|
||||
model = CloudPaymentsPayment
|
||||
payment_id_attr = 'invoice_id'
|
||||
extra_conditions = [CloudPaymentsPayment.status == 'completed', CloudPaymentsPayment.is_paid.is_(True)]
|
||||
elif base_method == 'freekassa':
|
||||
model = FreekassaPayment
|
||||
payment_id_attr = 'order_id'
|
||||
extra_conditions = [FreekassaPayment.status == 'success', FreekassaPayment.is_paid.is_(True)]
|
||||
elif base_method == 'kassa_ai':
|
||||
model = KassaAiPayment
|
||||
payment_id_attr = 'order_id'
|
||||
extra_conditions = [KassaAiPayment.status == 'success', KassaAiPayment.is_paid.is_(True)]
|
||||
elif base_method == 'riopay':
|
||||
model = RioPayPayment
|
||||
payment_id_attr = 'order_id'
|
||||
extra_conditions = [RioPayPayment.status == 'success', RioPayPayment.is_paid.is_(True)]
|
||||
elif base_method == 'severpay':
|
||||
model = SeverPayPayment
|
||||
payment_id_attr = 'order_id'
|
||||
extra_conditions = [SeverPayPayment.status == 'success', SeverPayPayment.is_paid.is_(True)]
|
||||
|
||||
if model is None:
|
||||
return None
|
||||
|
||||
result = await db.execute(
|
||||
select(model).where(
|
||||
model.metadata_json['purchase_token'].as_string() == purchase_token,
|
||||
*extra_conditions,
|
||||
)
|
||||
)
|
||||
p = result.scalars().first()
|
||||
if p is None:
|
||||
return None
|
||||
|
||||
payment_id = str(getattr(p, payment_id_attr))
|
||||
# amount_kopeks: Integer column for most providers, @property for Heleket
|
||||
amount = getattr(p, 'amount_kopeks', None)
|
||||
return (payment_id, amount)
|
||||
|
||||
|
||||
async def _check_and_recover_pending_purchase(
|
||||
db: AsyncSession,
|
||||
purchase_token: str,
|
||||
payment_method: str | None,
|
||||
) -> bool:
|
||||
"""Check if a PENDING purchase has a succeeded payment and transition to PAID.
|
||||
|
||||
Uses SELECT ... FOR UPDATE on the GuestPurchase row to prevent concurrent
|
||||
webhook processing from racing with the recovery.
|
||||
Verifies amount match between provider payment and guest purchase.
|
||||
"""
|
||||
from app.database.crud.landing import update_purchase_status
|
||||
|
||||
# Lock the row to prevent TOCTOU race with concurrent webhook processing
|
||||
result = await db.execute(select(GuestPurchase).where(GuestPurchase.token == purchase_token).with_for_update())
|
||||
purchase = result.scalars().first()
|
||||
if purchase is None or purchase.status != GuestPurchaseStatus.PENDING.value:
|
||||
return False
|
||||
|
||||
# Resolve base method: 'yookassa_sbp' → 'yookassa', 'kassa_ai' stays 'kassa_ai'
|
||||
base_method = _resolve_base_payment_method(payment_method)
|
||||
|
||||
match = await _find_succeeded_provider_payment(db, base_method, purchase_token)
|
||||
if match is None:
|
||||
if base_method:
|
||||
logger.debug(
|
||||
'No succeeded provider payment found for PENDING purchase',
|
||||
token_prefix=purchase_token[:5],
|
||||
payment_method=payment_method,
|
||||
)
|
||||
return False
|
||||
|
||||
provider_payment_id, provider_amount_kopeks = match
|
||||
|
||||
# Amount verification (skip when provider_amount_kopeks is None, e.g., crypto)
|
||||
if provider_amount_kopeks is not None and provider_amount_kopeks != purchase.amount_kopeks:
|
||||
logger.error(
|
||||
'Amount mismatch during PENDING recovery — skipping',
|
||||
token_prefix=purchase_token[:5],
|
||||
provider_amount=provider_amount_kopeks,
|
||||
purchase_amount=purchase.amount_kopeks,
|
||||
payment_method=payment_method,
|
||||
)
|
||||
# Mark FAILED to prevent repeated mismatch logs every cycle
|
||||
from app.database.crud.landing import update_purchase_status as _update_status
|
||||
|
||||
await _update_status(db, purchase_token, GuestPurchaseStatus.FAILED)
|
||||
await _send_amount_mismatch_alert(purchase, provider_amount_kopeks, provider_payment_id, payment_method)
|
||||
return False
|
||||
|
||||
# Transition PENDING → PAID for retry_stuck_paid_purchases to handle
|
||||
await update_purchase_status(
|
||||
db,
|
||||
purchase_token,
|
||||
GuestPurchaseStatus.PAID,
|
||||
payment_id=provider_payment_id,
|
||||
paid_at=datetime.now(UTC),
|
||||
)
|
||||
logger.info(
|
||||
'Recovered stuck PENDING purchase → PAID',
|
||||
token_prefix=purchase_token[:5],
|
||||
payment_method=payment_method,
|
||||
provider_payment_id=provider_payment_id,
|
||||
)
|
||||
return True
|
||||
|
||||
@@ -65,11 +65,11 @@ class MaintenanceService:
|
||||
return False
|
||||
|
||||
try:
|
||||
from app.services.admin_notification_service import AdminNotificationService
|
||||
from app.services.admin_notification_service import AdminNotificationService, NotificationCategory
|
||||
|
||||
notification_service = AdminNotificationService(self._bot)
|
||||
|
||||
if not notification_service._is_enabled():
|
||||
if not notification_service.is_enabled:
|
||||
logger.debug('Уведомления администраторов отключены')
|
||||
return False
|
||||
|
||||
@@ -79,7 +79,9 @@ class MaintenanceService:
|
||||
timestamp = format_local_datetime(datetime.now(UTC), '%d.%m.%Y %H:%M:%S %Z')
|
||||
formatted_message = f'{emoji} <b>ТЕХНИЧЕСКИЕ РАБОТЫ</b>\n\n{message}\n\n⏰ <i>{timestamp}</i>'
|
||||
|
||||
return await notification_service._send_message(formatted_message)
|
||||
return await notification_service.send_admin_notification(
|
||||
formatted_message, category=NotificationCategory.INFRASTRUCTURE
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
logger.error('Ошибка отправки уведомления через AdminNotificationService', error=e)
|
||||
|
||||
@@ -1023,7 +1023,7 @@ class MonitoringService:
|
||||
continue
|
||||
|
||||
days_before_expiry = (sub.end_date - current_time).days
|
||||
if days_before_expiry <= min(sub.autopay_days_before, 3):
|
||||
if days_before_expiry <= min(sub.autopay_days_before or 3, 3):
|
||||
autopay_subscriptions.append(sub)
|
||||
|
||||
processed_count = 0
|
||||
@@ -1742,18 +1742,35 @@ class MonitoringService:
|
||||
)
|
||||
|
||||
async def _retry_stuck_guest_purchases(self, db: AsyncSession):
|
||||
try:
|
||||
from app.services.guest_purchase_service import retry_stuck_paid_purchases, retry_stuck_pending_activation
|
||||
from app.services.guest_purchase_service import (
|
||||
recover_stuck_pending_purchases,
|
||||
retry_stuck_paid_purchases,
|
||||
retry_stuck_pending_activation,
|
||||
)
|
||||
|
||||
# Phase 1: Recover PENDING purchases where provider payment already succeeded
|
||||
try:
|
||||
recovered = await recover_stuck_pending_purchases(db, stale_minutes=10, limit=10)
|
||||
if recovered:
|
||||
logger.info('Recovered stuck PENDING purchases', recovered=recovered)
|
||||
except Exception:
|
||||
logger.error('Error recovering stuck PENDING guest purchases', exc_info=True)
|
||||
|
||||
# Phase 2: Retry fulfillment for purchases in PAID status
|
||||
try:
|
||||
retried = await retry_stuck_paid_purchases(db, stale_minutes=5, limit=10)
|
||||
if retried:
|
||||
logger.info('Retried stuck guest purchases', retried=retried)
|
||||
except Exception:
|
||||
logger.error('Error retrying stuck PAID guest purchases', exc_info=True)
|
||||
|
||||
# Phase 3: Retry activation for purchases in PENDING_ACTIVATION status
|
||||
try:
|
||||
retried_pa = await retry_stuck_pending_activation(db, stale_minutes=10, limit=10)
|
||||
if retried_pa:
|
||||
logger.info('Retried stuck pending_activation purchases', retried=retried_pa)
|
||||
except Exception:
|
||||
logger.error('Error retrying stuck guest purchases', exc_info=True)
|
||||
logger.error('Error retrying stuck PENDING_ACTIVATION guest purchases', exc_info=True)
|
||||
|
||||
async def _cleanup_inactive_users(self, db: AsyncSession):
|
||||
try:
|
||||
|
||||
@@ -476,8 +476,7 @@ async def try_fulfill_guest_purchase(
|
||||
introduces imprecision.
|
||||
|
||||
Returns:
|
||||
``True`` -- guest purchase was detected and successfully fulfilled.
|
||||
``False`` -- guest purchase was detected but fulfillment failed.
|
||||
``True`` -- guest purchase was detected and consumed (fulfilled or queued for retry).
|
||||
``None`` -- this is NOT a guest purchase (caller should proceed normally).
|
||||
"""
|
||||
purchase_token = _extract_guest_purchase_token(metadata)
|
||||
@@ -485,7 +484,7 @@ async def try_fulfill_guest_purchase(
|
||||
return None
|
||||
|
||||
from app.database.crud.landing import get_purchase_by_token, update_purchase_status
|
||||
from app.database.models import GuestPurchaseStatus
|
||||
from app.database.models import GuestPurchase, GuestPurchaseStatus
|
||||
from app.services.guest_purchase_service import fulfill_purchase
|
||||
|
||||
try:
|
||||
@@ -558,13 +557,26 @@ async def try_fulfill_guest_purchase(
|
||||
provider=provider_name,
|
||||
error=guest_error,
|
||||
)
|
||||
# Mark as FAILED so it doesn't get retried forever
|
||||
# Mark as PAID (not FAILED) so retry_stuck_paid_purchases can pick it up.
|
||||
# Use a fresh session to avoid tainted-session issues after rollback.
|
||||
# The monitoring service retries PAID purchases every 5 minutes for up to 24 hours.
|
||||
try:
|
||||
await update_purchase_status(
|
||||
db,
|
||||
purchase_token,
|
||||
GuestPurchaseStatus.FAILED,
|
||||
)
|
||||
from app.database.database import AsyncSessionLocal
|
||||
|
||||
async with AsyncSessionLocal() as recovery_db:
|
||||
# Use FOR UPDATE to prevent TOCTOU race with concurrent webhook.
|
||||
row = await recovery_db.execute(
|
||||
select(GuestPurchase).where(GuestPurchase.token == purchase_token).with_for_update()
|
||||
)
|
||||
current = row.scalars().first()
|
||||
if current and current.status in (
|
||||
GuestPurchaseStatus.PENDING.value,
|
||||
GuestPurchaseStatus.PAID.value,
|
||||
):
|
||||
current.status = GuestPurchaseStatus.PAID.value
|
||||
current.payment_id = provider_payment_id
|
||||
current.paid_at = datetime.now(UTC)
|
||||
await recovery_db.commit()
|
||||
except Exception:
|
||||
logger.exception('Failed to mark guest purchase as FAILED')
|
||||
return False
|
||||
logger.exception('Failed to mark guest purchase as PAID for retry')
|
||||
return True
|
||||
|
||||
@@ -87,7 +87,6 @@ class RioPayPaymentMixin:
|
||||
# Генерируем уникальный order_id с telegram_id для удобного поиска
|
||||
order_id = f'rp{tg_id}_{uuid.uuid4().hex[:6]}'
|
||||
amount_rubles = amount_kopeks / 100
|
||||
currency = settings.RIOPAY_CURRENCY
|
||||
|
||||
# Срок действия платежа (1 час по умолчанию)
|
||||
expires_at = datetime.now(UTC) + timedelta(hours=1)
|
||||
@@ -105,11 +104,9 @@ class RioPayPaymentMixin:
|
||||
# Используем API для создания заказа
|
||||
result = await riopay_service.create_order(
|
||||
amount=amount_rubles,
|
||||
currency=currency,
|
||||
external_id=order_id,
|
||||
purpose=description,
|
||||
success_url=success_url or settings.RIOPAY_SUCCESS_URL,
|
||||
fail_url=fail_url or settings.RIOPAY_FAIL_URL,
|
||||
)
|
||||
|
||||
payment_url = result.get('paymentLink')
|
||||
@@ -129,7 +126,7 @@ class RioPayPaymentMixin:
|
||||
user_id=user_id,
|
||||
order_id=order_id,
|
||||
amount_kopeks=amount_kopeks,
|
||||
currency=currency,
|
||||
currency=settings.RIOPAY_CURRENCY,
|
||||
description=description,
|
||||
payment_url=payment_url,
|
||||
riopay_order_id=riopay_order_id,
|
||||
@@ -143,7 +140,7 @@ class RioPayPaymentMixin:
|
||||
order_id=order_id,
|
||||
user_id=user_id,
|
||||
amount_rubles=amount_rubles,
|
||||
currency=currency,
|
||||
currency=settings.RIOPAY_CURRENCY,
|
||||
)
|
||||
|
||||
return {
|
||||
@@ -151,7 +148,7 @@ class RioPayPaymentMixin:
|
||||
'riopay_order_id': riopay_order_id,
|
||||
'amount_kopeks': amount_kopeks,
|
||||
'amount_rubles': amount_rubles,
|
||||
'currency': currency,
|
||||
'currency': settings.RIOPAY_CURRENCY,
|
||||
'payment_url': payment_url,
|
||||
'expires_at': expires_at.isoformat(),
|
||||
'local_payment_id': local_payment.id,
|
||||
|
||||
@@ -721,7 +721,7 @@ class PaymentService(
|
||||
payment_system_id=ps_id,
|
||||
)
|
||||
if result:
|
||||
await _patch_guest_metadata(result['local_payment_id'], payment_method)
|
||||
await _patch_guest_metadata(result['local_payment_id'], 'kassa_ai')
|
||||
return {
|
||||
'payment_url': result.get('payment_url'),
|
||||
'payment_id': result.get('order_id'),
|
||||
|
||||
@@ -8,6 +8,7 @@ from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.config import settings
|
||||
from app.database.crud.transaction import get_user_total_spent_kopeks
|
||||
from app.database.crud.user import lock_user_for_update
|
||||
from app.database.models import PromoGroup, User
|
||||
from app.services.admin_notification_service import AdminNotificationService
|
||||
|
||||
@@ -90,7 +91,9 @@ async def maybe_assign_promo_group_by_total_spent(
|
||||
) -> PromoGroup | None:
|
||||
from app.database.crud.user_promo_group import (
|
||||
add_user_to_promo_group,
|
||||
get_user_promo_groups,
|
||||
has_user_promo_group,
|
||||
remove_user_from_promo_group,
|
||||
sync_user_primary_promo_group,
|
||||
)
|
||||
|
||||
@@ -99,6 +102,9 @@ async def maybe_assign_promo_group_by_total_spent(
|
||||
logger.debug('Не удалось найти пользователя для автовыдачи промогруппы', user_id=user_id)
|
||||
return None
|
||||
|
||||
# Блокируем строку пользователя для предотвращения гонок при конкурентных вебхуках
|
||||
user = await lock_user_for_update(db, user)
|
||||
|
||||
# Получаем текущую primary промогруппу
|
||||
old_group = user.get_primary_promo_group()
|
||||
|
||||
@@ -108,60 +114,68 @@ async def maybe_assign_promo_group_by_total_spent(
|
||||
|
||||
previous_threshold = user.auto_promo_group_threshold_kopeks or 0
|
||||
|
||||
target_group = await _get_best_group_for_spending(
|
||||
db,
|
||||
total_spent,
|
||||
min_threshold_kopeks=previous_threshold,
|
||||
)
|
||||
# Находим группу, соответствующую текущим тратам (без порогового фильтра,
|
||||
# чтобы промокод-группы всегда очищались при покупке)
|
||||
target_group = await _get_best_group_for_spending(db, total_spent)
|
||||
if not target_group:
|
||||
return None
|
||||
|
||||
try:
|
||||
target_threshold = target_group.auto_assign_total_spent_kopeks or 0
|
||||
|
||||
if target_threshold <= previous_threshold:
|
||||
logger.debug(
|
||||
"Порог промогруппы '' не превышает ранее назначенный для пользователя",
|
||||
target_group_name=target_group.name,
|
||||
target_threshold=target_threshold,
|
||||
previous_threshold=previous_threshold,
|
||||
telegram_id=user.telegram_id,
|
||||
)
|
||||
return None
|
||||
# Фаза 1: Удаляем старые auto/promocode группы, отличные от целевой
|
||||
current_groups = await get_user_promo_groups(db, user_id)
|
||||
removed_any = False
|
||||
for upg in current_groups:
|
||||
if upg.promo_group_id != target_group.id and upg.assigned_by in ('auto', 'promocode'):
|
||||
await remove_user_from_promo_group(db, user_id, upg.promo_group_id, commit=False)
|
||||
removed_any = True
|
||||
logger.info(
|
||||
'Удалена старая промогруппа перед автоназначением',
|
||||
telegram_id=user.telegram_id,
|
||||
old_group_name=upg.promo_group.name if upg.promo_group else upg.promo_group_id,
|
||||
old_assigned_by=upg.assigned_by,
|
||||
)
|
||||
|
||||
# Проверяем, есть ли уже эта группа у пользователя
|
||||
if removed_any:
|
||||
await db.flush()
|
||||
await db.refresh(user)
|
||||
|
||||
# Проверяем, есть ли уже целевая группа у пользователя
|
||||
already_has_group = await has_user_promo_group(db, user_id, target_group.id)
|
||||
|
||||
if user.auto_promo_group_assigned and already_has_group:
|
||||
if user.auto_promo_group_assigned and already_has_group and not removed_any:
|
||||
logger.debug(
|
||||
"Пользователь уже имеет промогруппу '', повторная выдача не требуется",
|
||||
'Пользователь уже имеет промогруппу, повторная выдача не требуется',
|
||||
telegram_id=user.telegram_id,
|
||||
target_group_name=target_group.name,
|
||||
)
|
||||
await sync_user_primary_promo_group(db, user_id)
|
||||
if target_threshold > previous_threshold:
|
||||
user.auto_promo_group_threshold_kopeks = target_threshold
|
||||
user.updated_at = datetime.now(UTC)
|
||||
await db.commit()
|
||||
await db.refresh(user)
|
||||
await db.commit()
|
||||
await db.refresh(user)
|
||||
return target_group
|
||||
|
||||
user.auto_promo_group_assigned = True
|
||||
user.auto_promo_group_threshold_kopeks = target_threshold
|
||||
if target_threshold > previous_threshold:
|
||||
user.auto_promo_group_threshold_kopeks = target_threshold
|
||||
user.updated_at = datetime.now(UTC)
|
||||
|
||||
newly_added = False
|
||||
if not already_has_group:
|
||||
# Добавляем новую промогруппу к существующим
|
||||
await add_user_to_promo_group(db, user_id, target_group.id, assigned_by='auto')
|
||||
await add_user_to_promo_group(db, user_id, target_group.id, assigned_by='auto', commit=False)
|
||||
newly_added = True
|
||||
logger.info(
|
||||
"🤖 Пользователю добавлена промогруппа '' за траты ₽",
|
||||
'Пользователю назначена промогруппа за траты',
|
||||
telegram_id=user.telegram_id,
|
||||
target_group_name=target_group.name,
|
||||
total_spent=total_spent / 100,
|
||||
)
|
||||
else:
|
||||
await sync_user_primary_promo_group(db, user_id)
|
||||
logger.info(
|
||||
"🤖 Пользователь уже имеет промогруппу '', отмечаем автоприсвоение",
|
||||
'Пользователь уже имеет промогруппу, синхронизировано',
|
||||
telegram_id=user.telegram_id,
|
||||
target_group_name=target_group.name,
|
||||
)
|
||||
@@ -169,7 +183,7 @@ async def maybe_assign_promo_group_by_total_spent(
|
||||
await db.commit()
|
||||
await db.refresh(user)
|
||||
|
||||
if not already_has_group:
|
||||
if newly_added:
|
||||
await _notify_admins_about_auto_assignment(
|
||||
db,
|
||||
user,
|
||||
|
||||
@@ -119,7 +119,7 @@ class PromoCodeService:
|
||||
if promo_group:
|
||||
# Add promo group to user
|
||||
await add_user_to_promo_group(
|
||||
db, user_id, promocode.promo_group_id, assigned_by='promocode'
|
||||
db, user_id, promocode.promo_group_id, assigned_by='promocode', commit=False
|
||||
)
|
||||
|
||||
logger.info(
|
||||
@@ -393,7 +393,7 @@ class PromoCodeService:
|
||||
|
||||
has_group = await has_user_promo_group(db, user_id, promocode.promo_group_id)
|
||||
if has_group:
|
||||
await remove_user_from_promo_group(db, user_id, promocode.promo_group_id)
|
||||
await remove_user_from_promo_group(db, user_id, promocode.promo_group_id, commit=False)
|
||||
logger.info(
|
||||
'Снята промогруппа ID у пользователя при деактивации промокода',
|
||||
promo_group_id=promocode.promo_group_id,
|
||||
|
||||
@@ -305,7 +305,7 @@ class ReferralContestService:
|
||||
chat_id=chat_id,
|
||||
text='\n'.join(lines),
|
||||
disable_web_page_preview=True,
|
||||
message_thread_id=settings.ADMIN_NOTIFICATIONS_TOPIC_ID,
|
||||
message_thread_id=settings.ADMIN_NOTIFICATIONS_PROMO_TOPIC_ID or settings.ADMIN_NOTIFICATIONS_TOPIC_ID,
|
||||
)
|
||||
except Exception as exc:
|
||||
logger.error('Не удалось отправить админскую сводку конкурса', exc=exc)
|
||||
|
||||
@@ -654,13 +654,14 @@ class RemnaWaveWebhookService:
|
||||
changed = True
|
||||
|
||||
# Sync subscription crypto link (for HAPP_CRYPT4_LINK)
|
||||
subscription_crypto_link = data.get('subscriptionCryptoLink')
|
||||
if (
|
||||
subscription_crypto_link
|
||||
and self._is_valid_link(subscription_crypto_link)
|
||||
and subscription.subscription_crypto_link != subscription_crypto_link
|
||||
):
|
||||
subscription.subscription_crypto_link = subscription_crypto_link
|
||||
subscription_crypto_link = data.get('subscriptionCryptoLink') or (data.get('happ') or {}).get('cryptoLink', '')
|
||||
if subscription_crypto_link and self._is_valid_link(subscription_crypto_link):
|
||||
if subscription.subscription_crypto_link != subscription_crypto_link:
|
||||
subscription.subscription_crypto_link = subscription_crypto_link
|
||||
changed = True
|
||||
elif subscription_url and subscription.subscription_crypto_link:
|
||||
# URL обновился, а крипто-ссылка не пришла — сбрасываем старую
|
||||
subscription.subscription_crypto_link = None
|
||||
changed = True
|
||||
|
||||
# Always stamp to protect from sync overwrite, even if no fields changed
|
||||
@@ -743,18 +744,18 @@ class RemnaWaveWebhookService:
|
||||
) -> None:
|
||||
if subscription:
|
||||
new_url = data.get('subscriptionUrl')
|
||||
new_crypto_link = data.get('subscriptionCryptoLink')
|
||||
new_crypto_link = data.get('subscriptionCryptoLink') or (data.get('happ') or {}).get('cryptoLink', '')
|
||||
changed = False
|
||||
|
||||
if new_url and self._is_valid_url(new_url) and subscription.subscription_url != new_url:
|
||||
subscription.subscription_url = new_url
|
||||
changed = True
|
||||
if (
|
||||
new_crypto_link
|
||||
and self._is_valid_link(new_crypto_link)
|
||||
and subscription.subscription_crypto_link != new_crypto_link
|
||||
):
|
||||
subscription.subscription_crypto_link = new_crypto_link
|
||||
if new_crypto_link and self._is_valid_link(new_crypto_link):
|
||||
if subscription.subscription_crypto_link != new_crypto_link:
|
||||
subscription.subscription_crypto_link = new_crypto_link
|
||||
changed = True
|
||||
elif new_url and subscription.subscription_crypto_link:
|
||||
subscription.subscription_crypto_link = None
|
||||
changed = True
|
||||
|
||||
# Always stamp to protect from sync overwrite
|
||||
|
||||
@@ -45,7 +45,7 @@ class RioPayService:
|
||||
def _get_headers(self) -> dict[str, str]:
|
||||
"""Формирует заголовки для API запросов."""
|
||||
return {
|
||||
'x-api-token': self.api_token,
|
||||
'X-Api-Token': self.api_token,
|
||||
'Content-Type': 'application/json',
|
||||
}
|
||||
|
||||
@@ -67,11 +67,9 @@ class RioPayService:
|
||||
self,
|
||||
*,
|
||||
amount: float,
|
||||
currency: str = 'RUB',
|
||||
external_id: str,
|
||||
purpose: str = 'Пополнение баланса',
|
||||
success_url: str | None = None,
|
||||
fail_url: str | None = None,
|
||||
) -> dict[str, Any]:
|
||||
"""
|
||||
Создает заказ через API RioPay.
|
||||
@@ -82,21 +80,17 @@ class RioPayService:
|
||||
"""
|
||||
payload: dict[str, Any] = {
|
||||
'amount': str(amount),
|
||||
'currency': currency,
|
||||
'externalId': external_id,
|
||||
'purpose': purpose,
|
||||
}
|
||||
|
||||
if success_url:
|
||||
payload['successUrl'] = success_url
|
||||
if fail_url:
|
||||
payload['failUrl'] = fail_url
|
||||
|
||||
logger.info(
|
||||
'RioPay API create_order',
|
||||
external_id=external_id,
|
||||
amount=amount,
|
||||
currency=currency,
|
||||
)
|
||||
|
||||
try:
|
||||
|
||||
@@ -65,7 +65,10 @@ class StartupNotificationService:
|
||||
def __init__(self, bot: Bot) -> None:
|
||||
self.bot = bot
|
||||
self.chat_id = getattr(settings, 'ADMIN_NOTIFICATIONS_CHAT_ID', None)
|
||||
self.topic_id = getattr(settings, 'ADMIN_NOTIFICATIONS_TOPIC_ID', None)
|
||||
# Стартовые/краш-уведомления → топик инфраструктуры, fallback на общий
|
||||
self.topic_id = getattr(settings, 'ADMIN_NOTIFICATIONS_INFRASTRUCTURE_TOPIC_ID', None) or getattr(
|
||||
settings, 'ADMIN_NOTIFICATIONS_TOPIC_ID', None
|
||||
)
|
||||
self.enabled = getattr(settings, 'ADMIN_NOTIFICATIONS_ENABLED', False)
|
||||
|
||||
def _get_version(self) -> str:
|
||||
@@ -381,7 +384,10 @@ async def send_crash_notification(bot: Bot, error: Exception, traceback_str: str
|
||||
bool: True если уведомление отправлено успешно
|
||||
"""
|
||||
chat_id = getattr(settings, 'ADMIN_NOTIFICATIONS_CHAT_ID', None)
|
||||
topic_id = getattr(settings, 'ADMIN_NOTIFICATIONS_TOPIC_ID', None)
|
||||
# Краш → топик ошибок, fallback на общий
|
||||
topic_id = getattr(settings, 'ADMIN_NOTIFICATIONS_ERRORS_TOPIC_ID', None) or getattr(
|
||||
settings, 'ADMIN_NOTIFICATIONS_TOPIC_ID', None
|
||||
)
|
||||
enabled = getattr(settings, 'ADMIN_NOTIFICATIONS_ENABLED', False)
|
||||
|
||||
if not enabled or not chat_id:
|
||||
|
||||
@@ -260,6 +260,15 @@ class BotConfigurationService:
|
||||
'ADMIN_NOTIFICATIONS_CHAT_ID': 'ADMIN_NOTIFICATIONS',
|
||||
'ADMIN_NOTIFICATIONS_TOPIC_ID': 'ADMIN_NOTIFICATIONS',
|
||||
'ADMIN_NOTIFICATIONS_TICKET_TOPIC_ID': 'ADMIN_NOTIFICATIONS',
|
||||
'ADMIN_NOTIFICATIONS_PURCHASES_TOPIC_ID': 'ADMIN_NOTIFICATIONS',
|
||||
'ADMIN_NOTIFICATIONS_RENEWALS_TOPIC_ID': 'ADMIN_NOTIFICATIONS',
|
||||
'ADMIN_NOTIFICATIONS_TRIALS_TOPIC_ID': 'ADMIN_NOTIFICATIONS',
|
||||
'ADMIN_NOTIFICATIONS_BALANCE_TOPIC_ID': 'ADMIN_NOTIFICATIONS',
|
||||
'ADMIN_NOTIFICATIONS_ADDONS_TOPIC_ID': 'ADMIN_NOTIFICATIONS',
|
||||
'ADMIN_NOTIFICATIONS_INFRASTRUCTURE_TOPIC_ID': 'ADMIN_NOTIFICATIONS',
|
||||
'ADMIN_NOTIFICATIONS_ERRORS_TOPIC_ID': 'ADMIN_NOTIFICATIONS',
|
||||
'ADMIN_NOTIFICATIONS_PROMO_TOPIC_ID': 'ADMIN_NOTIFICATIONS',
|
||||
'ADMIN_NOTIFICATIONS_PARTNERS_TOPIC_ID': 'ADMIN_NOTIFICATIONS',
|
||||
'ADMIN_REPORTS_ENABLED': 'ADMIN_REPORTS',
|
||||
'ADMIN_REPORTS_CHAT_ID': 'ADMIN_REPORTS',
|
||||
'ADMIN_REPORTS_TOPIC_ID': 'ADMIN_REPORTS',
|
||||
@@ -275,7 +284,6 @@ class BotConfigurationService:
|
||||
'SIMPLE_SUBSCRIPTION_DEVICE_LIMIT': 'SIMPLE_SUBSCRIPTION',
|
||||
'SIMPLE_SUBSCRIPTION_TRAFFIC_GB': 'SIMPLE_SUBSCRIPTION',
|
||||
'SIMPLE_SUBSCRIPTION_SQUAD_UUID': 'SIMPLE_SUBSCRIPTION',
|
||||
'DISABLE_TOPUP_BUTTONS': 'PAYMENT',
|
||||
'SUPPORT_TOPUP_ENABLED': 'PAYMENT',
|
||||
'ENABLE_NOTIFICATIONS': 'NOTIFICATIONS',
|
||||
'NOTIFICATION_RETRY_ATTEMPTS': 'NOTIFICATIONS',
|
||||
@@ -572,7 +580,7 @@ class BotConfigurationService:
|
||||
'format': 'Булево значение.',
|
||||
'example': 'Включите после указания токена API и секрета вебхука.',
|
||||
'warning': 'Пустой токен или неверный вебхук приведут к отказам платежей.',
|
||||
'dependencies': 'CRYPTOBOT_API_TOKEN, CRYPTOBOT_WEBHOOK_SECRET',
|
||||
'dependencies': 'CRYPTOBOT_API_TOKEN',
|
||||
},
|
||||
'PAYMENT_VERIFICATION_AUTO_CHECK_ENABLED': {
|
||||
'description': (
|
||||
|
||||
+9
-2
@@ -400,9 +400,16 @@ return c
|
||||
return fail_closed
|
||||
|
||||
@staticmethod
|
||||
async def is_rate_limited(user_id: int, action: str, limit: int, window: int) -> bool:
|
||||
async def is_rate_limited(
|
||||
user_id: int,
|
||||
action: str,
|
||||
limit: int,
|
||||
window: int,
|
||||
*,
|
||||
fail_closed: bool = False,
|
||||
) -> bool:
|
||||
key = cache_key('rate_limit', user_id, action)
|
||||
return await RateLimitCache._atomic_rate_check(key, limit, window)
|
||||
return await RateLimitCache._atomic_rate_check(key, limit, window, fail_closed=fail_closed)
|
||||
|
||||
@staticmethod
|
||||
async def reset_rate_limit(user_id: int, action: str) -> bool:
|
||||
|
||||
@@ -312,7 +312,7 @@ def create_payment_router(bot: Bot, payment_service: PaymentService) -> APIRoute
|
||||
)
|
||||
|
||||
signature = request.headers.get('Crypto-Pay-API-Signature')
|
||||
secret = settings.CRYPTOBOT_WEBHOOK_SECRET
|
||||
secret = settings.CRYPTOBOT_API_TOKEN
|
||||
if secret:
|
||||
if not signature:
|
||||
return JSONResponse(
|
||||
@@ -682,6 +682,10 @@ def create_payment_router(bot: Bot, payment_service: PaymentService) -> APIRoute
|
||||
async def platega_webhook(request: Request) -> JSONResponse:
|
||||
merchant_id = request.headers.get('X-MerchantId', '')
|
||||
secret = request.headers.get('X-Secret', '')
|
||||
raw_body = await request.body()
|
||||
if not merchant_id and not secret and not raw_body.strip():
|
||||
logger.info('Platega webhook verification ping (no auth headers, empty body)')
|
||||
return JSONResponse({'status': 'ok'})
|
||||
if merchant_id != (settings.PLATEGA_MERCHANT_ID or '') or secret != (settings.PLATEGA_SECRET or ''):
|
||||
return JSONResponse(
|
||||
{'status': 'error', 'reason': 'unauthorized'},
|
||||
|
||||
@@ -45,7 +45,11 @@ def run_migrations_offline() -> None:
|
||||
|
||||
|
||||
def do_run_migrations(connection: Connection) -> None:
|
||||
context.configure(connection=connection, target_metadata=target_metadata)
|
||||
context.configure(
|
||||
connection=connection,
|
||||
target_metadata=target_metadata,
|
||||
transaction_per_migration=True,
|
||||
)
|
||||
|
||||
with context.begin_transaction():
|
||||
context.run_migrations()
|
||||
|
||||
@@ -0,0 +1,56 @@
|
||||
"""add performance indexes for referral network queries
|
||||
|
||||
Revision ID: 0041
|
||||
Revises: 0040
|
||||
Create Date: 2026-03-20
|
||||
|
||||
"""
|
||||
|
||||
from typing import Sequence, Union
|
||||
|
||||
from alembic import op
|
||||
|
||||
revision: str = '0041'
|
||||
down_revision: Union[str, None] = '0040'
|
||||
branch_labels: Union[str, Sequence[str], None] = None
|
||||
depends_on: Union[str, Sequence[str], None] = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
# CREATE INDEX CONCURRENTLY cannot run inside a transaction.
|
||||
# autocommit_block() temporarily disables the transaction wrapper.
|
||||
#
|
||||
# NOTE: If a concurrent index creation fails midway, PostgreSQL leaves behind
|
||||
# an INVALID index. Check with:
|
||||
# SELECT indexrelname FROM pg_stat_user_indexes
|
||||
# JOIN pg_index ON pg_index.indexrelid = pg_stat_user_indexes.indexrelid
|
||||
# WHERE NOT pg_index.indisvalid;
|
||||
# Then drop the invalid index and re-run the migration.
|
||||
with op.get_context().autocommit_block():
|
||||
# Index on advertising_campaign_registrations(user_id, created_at)
|
||||
# Fixes sequential scan in _fetch_campaign_registrations which filters by user_id
|
||||
# and uses ROW_NUMBER() OVER (PARTITION BY user_id ORDER BY created_at)
|
||||
op.create_index(
|
||||
'ix_campaign_reg_user_created',
|
||||
'advertising_campaign_registrations',
|
||||
['user_id', 'created_at'],
|
||||
if_not_exists=True,
|
||||
postgresql_concurrently=True,
|
||||
)
|
||||
|
||||
# Covering composite index on transactions(user_id, type, is_completed, amount_kopeks)
|
||||
# Enables index-only scans for aggregation queries in referral network stats:
|
||||
# _fetch_personal_spent, _fetch_branch_revenue, _fetch_campaign_stats
|
||||
op.create_index(
|
||||
'ix_transactions_user_type_completed_amount',
|
||||
'transactions',
|
||||
['user_id', 'type', 'is_completed', 'amount_kopeks'],
|
||||
if_not_exists=True,
|
||||
postgresql_concurrently=True,
|
||||
)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
with op.get_context().autocommit_block():
|
||||
op.execute('DROP INDEX CONCURRENTLY IF EXISTS ix_transactions_user_type_completed_amount')
|
||||
op.execute('DROP INDEX CONCURRENTLY IF EXISTS ix_campaign_reg_user_created')
|
||||
@@ -0,0 +1,88 @@
|
||||
"""add retry_count to guest_purchases and expression indexes for payment recovery
|
||||
|
||||
Revision ID: 0042
|
||||
Revises: 0041
|
||||
Create Date: 2026-03-20
|
||||
|
||||
"""
|
||||
|
||||
from typing import Sequence, Union
|
||||
|
||||
import sqlalchemy as sa
|
||||
from alembic import op
|
||||
|
||||
revision: str = '0042'
|
||||
down_revision: Union[str, None] = '0041'
|
||||
branch_labels: Union[str, Sequence[str], None] = None
|
||||
depends_on: Union[str, Sequence[str], None] = None
|
||||
|
||||
# Payment tables with metadata_json + is_paid column.
|
||||
_TABLES_WITH_IS_PAID = [
|
||||
'yookassa_payments',
|
||||
'mulenpay_payments',
|
||||
'pal24_payments',
|
||||
'wata_payments',
|
||||
'platega_payments',
|
||||
'cloudpayments_payments',
|
||||
'freekassa_payments',
|
||||
'kassa_ai_payments',
|
||||
'riopay_payments',
|
||||
'severpay_payments',
|
||||
]
|
||||
|
||||
# All tables that get a metadata purchase_token index (for downgrade)
|
||||
_ALL_METADATA_TABLES = [*_TABLES_WITH_IS_PAID, 'heleket_payments']
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
# 1. Add retry_count column to guest_purchases (safe: has server_default)
|
||||
op.add_column(
|
||||
'guest_purchases',
|
||||
sa.Column('retry_count', sa.Integer(), nullable=False, server_default='0'),
|
||||
)
|
||||
|
||||
# 2. Create expression indexes for payment recovery queries.
|
||||
# These allow efficient lookup of succeeded payments by purchase_token
|
||||
# stored inside the metadata_json column.
|
||||
with op.get_context().autocommit_block():
|
||||
# Tables with is_paid boolean column
|
||||
for table in _TABLES_WITH_IS_PAID:
|
||||
idx_name = f'ix_{table}_metadata_purchase_token'
|
||||
op.execute(
|
||||
sa.text(
|
||||
f'CREATE INDEX CONCURRENTLY IF NOT EXISTS {idx_name} '
|
||||
f"ON {table} ((metadata_json ->> 'purchase_token')) "
|
||||
f'WHERE is_paid = TRUE'
|
||||
)
|
||||
)
|
||||
|
||||
# Heleket: no is_paid column (it's a Python @property), use status filter
|
||||
op.execute(
|
||||
sa.text(
|
||||
'CREATE INDEX CONCURRENTLY IF NOT EXISTS ix_heleket_payments_metadata_purchase_token '
|
||||
"ON heleket_payments ((metadata_json ->> 'purchase_token')) "
|
||||
"WHERE status IN ('paid', 'paid_over')"
|
||||
)
|
||||
)
|
||||
|
||||
# CryptoBot: payload (text) column with JSON inside, no metadata_json
|
||||
op.execute(
|
||||
sa.text(
|
||||
'CREATE INDEX CONCURRENTLY IF NOT EXISTS ix_cryptobot_payments_payload_purchase_token '
|
||||
"ON cryptobot_payments ((CAST(payload AS json) ->> 'purchase_token')) "
|
||||
"WHERE status = 'paid'"
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
with op.get_context().autocommit_block():
|
||||
for table in _ALL_METADATA_TABLES:
|
||||
idx_name = f'ix_{table}_metadata_purchase_token'
|
||||
op.execute(sa.text(f'DROP INDEX CONCURRENTLY IF EXISTS {idx_name}'))
|
||||
|
||||
op.execute(
|
||||
sa.text('DROP INDEX CONCURRENTLY IF EXISTS ix_cryptobot_payments_payload_purchase_token')
|
||||
)
|
||||
|
||||
op.drop_column('guest_purchases', 'retry_count')
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
[project]
|
||||
name = 'remnawave-bedolaga-telegram-bot'
|
||||
version = "3.34.0"
|
||||
version = "3.36.0"
|
||||
description = 'Telegram bot for RemnaWave VPN service'
|
||||
readme = 'README.md'
|
||||
license = { text = 'MIT' }
|
||||
|
||||
+3
-4
@@ -27,7 +27,6 @@ def anyio_backend() -> str:
|
||||
def _enable_token(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
monkeypatch.setattr(settings, 'CRYPTOBOT_API_TOKEN', 'token', raising=False)
|
||||
monkeypatch.setattr(type(settings), 'get_cryptobot_base_url', lambda self: 'https://cryptobot.test', raising=False)
|
||||
monkeypatch.setattr(settings, 'CRYPTOBOT_WEBHOOK_SECRET', 'secret', raising=False)
|
||||
|
||||
|
||||
@pytest.mark.anyio('asyncio')
|
||||
@@ -69,7 +68,7 @@ async def test_make_request_returns_none_without_token(monkeypatch: pytest.Monke
|
||||
|
||||
|
||||
def test_verify_webhook_signature(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
monkeypatch.setattr(settings, 'CRYPTOBOT_WEBHOOK_SECRET', 'supersecret', raising=False)
|
||||
monkeypatch.setattr(settings, 'CRYPTOBOT_API_TOKEN', 'supersecret', raising=False)
|
||||
service = CryptoBotService()
|
||||
|
||||
body = '{"invoice_id":1}'
|
||||
@@ -80,7 +79,7 @@ def test_verify_webhook_signature(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
assert service.verify_webhook_signature(body, 'invalid') is False
|
||||
|
||||
|
||||
def test_verify_webhook_signature_without_secret(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
monkeypatch.setattr(settings, 'CRYPTOBOT_WEBHOOK_SECRET', '', raising=False)
|
||||
def test_verify_webhook_signature_without_token(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
monkeypatch.setattr(settings, 'CRYPTOBOT_API_TOKEN', '', raising=False)
|
||||
service = CryptoBotService()
|
||||
assert service.verify_webhook_signature('{}', 'anything') is True
|
||||
|
||||
Reference in New Issue
Block a user