Compare commits
91 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| efa1b11db5 | |||
| d0ce193edb | |||
| 92d872236f | |||
| a11f492801 | |||
| c8162505ed | |||
| 076290e0c1 | |||
| bf72f241d8 | |||
| 12ae871653 | |||
| 8a362db783 | |||
| b1e2146254 | |||
| 68bc8eb57c | |||
| 9957259881 | |||
| b3f3eba575 | |||
| a798f1143e | |||
| cb5126aff8 | |||
| 8b35428055 | |||
| 5424d8c314 | |||
| df7411138e | |||
| 4545bef7ea | |||
| d7eb1e776a | |||
| f8fc382143 | |||
| e67b8e448e | |||
| bca8bab433 | |||
| 2fd0f6aa4e | |||
| 864a4ed700 | |||
| 2879996455 | |||
| 4b4fced442 | |||
| 8859e73890 | |||
| d79866819c | |||
| 5a62f91ca2 | |||
| def594bbb5 | |||
| 641ff86bf6 | |||
| 2e59330e95 | |||
| 3c96c2affd | |||
| 541f64d5bc | |||
| e82a1ccf6d | |||
| 015be30a27 | |||
| 6817b9e256 | |||
| 59248011c2 | |||
| 39d007ff3e | |||
| 94b211e2a7 | |||
| c84dbf82fc | |||
| 9ad684c8c9 | |||
| d147be0316 | |||
| 9281523e96 | |||
| fd3466b75c | |||
| dcfd54a7cb | |||
| 5c2e5dfaab | |||
| df112f3659 | |||
| a90d2d9367 | |||
| 5c34656476 | |||
| 8a8337f538 | |||
| 42b6c80a48 | |||
| b30c73c300 | |||
| 363ccce56d | |||
| 0005d59da1 | |||
| 38c6adfdb4 | |||
| 4fb72ae6e3 | |||
| 05bcac502e | |||
| 769d3a0b30 | |||
| 5ffce175dc | |||
| 1a2f0fcbe8 | |||
| fd1e728396 | |||
| ec41d65501 | |||
| 5212877801 | |||
| bc9003c336 | |||
| fcdeff1ee5 | |||
| c7bebae14a | |||
| 8ee287f8cd | |||
| 6f99b83c61 | |||
| 1a3c6fafa3 | |||
| be2ec091a6 | |||
| d4dc0b76ba | |||
| 2dfd0e6452 | |||
| 8e53b81b3d | |||
| 69ca37bc6e | |||
| 26daf9f6c8 | |||
| 34aae0dd26 | |||
| 0551a6e23c | |||
| 92cc602892 | |||
| 555b887952 | |||
| 848c9f71a2 | |||
| 4477e03d83 | |||
| a6849242ff | |||
| 861ffe5424 | |||
| 06ccf4b275 | |||
| 7ed91b13eb | |||
| 319f49435a | |||
| 23761a74f2 | |||
| 8620aaedb1 | |||
| aaffc26a90 |
@@ -369,6 +369,8 @@ REFERRAL_MINIMUM_TOPUP_KOPEKS=10000
|
||||
REFERRAL_FIRST_TOPUP_BONUS_KOPEKS=10000
|
||||
REFERRAL_INVITER_BONUS_KOPEKS=10000
|
||||
REFERRAL_COMMISSION_PERCENT=25
|
||||
# Макс. кол-во платежей реферала, с которых начисляется комиссия (0 = без лимита)
|
||||
REFERRAL_MAX_COMMISSION_PAYMENTS=0
|
||||
# Показывать раздел партнёрки в кабинете
|
||||
REFERRAL_PARTNER_SECTION_VISIBLE=true
|
||||
|
||||
@@ -492,6 +494,11 @@ 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
|
||||
# Отключить пополнение баланса через поддержку
|
||||
@@ -655,6 +662,20 @@ KASSA_AI_WEBHOOK_PORT=8089
|
||||
# Способ оплаты: 44 = СБП (QR), 36 = Карты РФ, 43 = SberPay
|
||||
KASSA_AI_PAYMENT_SYSTEM_ID=44
|
||||
|
||||
# ===== RIOPAY (api.riopay.online) =====
|
||||
RIOPAY_ENABLED=false
|
||||
RIOPAY_API_TOKEN=
|
||||
# Ключ для HMAC-SHA512 верификации вебхуков (если не указан, используется RIOPAY_API_TOKEN)
|
||||
RIOPAY_WEBHOOK_SECRET=
|
||||
RIOPAY_DISPLAY_NAME=RioPay
|
||||
RIOPAY_CURRENCY=RUB
|
||||
RIOPAY_MIN_AMOUNT_KOPEKS=10000
|
||||
RIOPAY_MAX_AMOUNT_KOPEKS=100000000
|
||||
RIOPAY_WEBHOOK_PATH=/riopay-webhook
|
||||
# URL для редиректа после оплаты (опционально)
|
||||
RIOPAY_SUCCESS_URL=
|
||||
RIOPAY_FAIL_URL=
|
||||
|
||||
# ===== WATA =====
|
||||
WATA_ENABLED=false
|
||||
WATA_BASE_URL=https://api.wata.pro
|
||||
|
||||
@@ -1,3 +1,3 @@
|
||||
{
|
||||
".": "3.28.0"
|
||||
".": "3.31.0"
|
||||
}
|
||||
|
||||
@@ -1,5 +1,82 @@
|
||||
# Changelog
|
||||
|
||||
## [3.31.0](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/compare/v3.30.0...v3.31.0) (2026-03-12)
|
||||
|
||||
|
||||
### New Features
|
||||
|
||||
* add show_in_gift toggle for tariffs in admin panel ([cb5126a](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/cb5126aff8c15938a59ea9c4f8e605b250b05dbc))
|
||||
* add sync-squads endpoint for bulk updating subscription squads in Remnawave ([b1e2146](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/b1e2146254255586b5be9bd894ac4d113a0a8cf5))
|
||||
* auto-sync squads to Remnawave when admin updates tariff ([076290e](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/076290e0c1d81b610a7653d6b64ed218e0f124b4))
|
||||
* referral links now point to web cabinet instead of bot ([12ae871](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/12ae871653399bc4ccd23b6394878e814ce9cd75))
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* add post_update=True to User.referrals self-referential relationship ([9957259](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/995725988150f31d193631120a4692e88fa4dd57))
|
||||
* add Telegram Stars payment support for gift subscriptions ([5424d8c](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/5424d8c31484873b0adc0bc980abdc51ee81325b))
|
||||
* correct skipped_count in sync-squads circuit breaker and simplify ternary ([8a362db](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/8a362db7833b5b7793b5b52345d227cb84cbc39e))
|
||||
* preserve purchased devices when admin changes user tariff ([bf72f24](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/bf72f241d81e4432f50a61ec3bb829d18c92955d))
|
||||
* prevent account takeover via auto_login_token, ensure promo group on all purchase paths ([b3f3eba](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/b3f3eba5756404df9ed0f12d8048244ca536f7d3))
|
||||
* reactivate subscription after traffic top-up when status is EXPIRED ([8b35428](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/8b354280558a5f28d1b99eae55ccd21a4af6a07b))
|
||||
* update promo group via M2M table so admin changes persist ([68bc8eb](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/68bc8eb57c792059d2be8a8fff6bba3254d3773d))
|
||||
|
||||
|
||||
### Refactoring
|
||||
|
||||
* remove estimated price from balance, simplify server sync, fix HTML injection ([a798f11](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/a798f1143eebf52e18254bddd610f7f14a0c4056))
|
||||
|
||||
## [3.30.0](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/compare/v3.29.0...v3.30.0) (2026-03-11)
|
||||
|
||||
|
||||
### New Features
|
||||
|
||||
* add gifts section to admin user detail API ([bca8bab](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/bca8bab4336b2583da9be8c642985e6a0151e33d))
|
||||
* add promo group and promo offer discounts to gift subscriptions ([2fd0f6a](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/2fd0f6aa4eb62f704208c1e56a6542d3967e7867))
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* record transactions for free tariff switches and admin tariff changes ([864a4ed](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/864a4ed7005195ff3be3a8bb2e7666bc5a7f3e4e))
|
||||
* reset subscription for paid users, trial-to-paid tariff conversion, gift purchase MissingGreenlet ([e67b8e4](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/e67b8e448e5396ee6daa8c6278bb5a0b313dda74))
|
||||
* use keyword args for Path.mkdir in asyncio.to_thread ([2879996](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/287999645506a49b6693a184757598e1cdceb4d8))
|
||||
|
||||
## [3.29.0](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/compare/v3.28.1...v3.29.0) (2026-03-10)
|
||||
|
||||
|
||||
### New Features
|
||||
|
||||
* gift subscription code-only purchase + activation via deep link ([5ffce17](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/5ffce175dcb8aebf22cf536bfa032c66da284600))
|
||||
* prevent self-activation of gift codes ([b30c73c](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/b30c73c300019646ea4a0d7e1bf758464ee58f0f))
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* 3 bugs — notification type, referral with channel sub, BOT_USERNAME ([3c96c2a](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/3c96c2affd5a803311e3c0c9a0f844d2217f387a))
|
||||
* 3 critical issues from second-round review ([a90d2d9](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/a90d2d936793daaadf116cf314b736a9ebfb7c3b))
|
||||
* add minimum 8-char length check for gift token in bot deep link ([8a8337f](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/8a8337f538c3fcaf84b84c34b7a1e38a4ce9d580))
|
||||
* address review findings from 6-agent audit ([5c34656](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/5c3465647639d6f07432bc3d725bba9396af6c45))
|
||||
* code-only gifts skip fulfillment in gateway webhook + retry service ([05bcac5](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/05bcac502efb1b4298a1c6be91bba5d7c057b9f0))
|
||||
* panel sync now updates end_date in both directions ([def594b](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/def594bbb55ef45d3df81524bd8841de73a07340))
|
||||
* pass full token to svc_activate instead of truncated prefix ([38c6adf](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/38c6adfdb4d4fc786bf6ca34a5d54025126130c0))
|
||||
* refresh user subscription after gift activation in /start ([363ccce](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/363ccce56d3e61554ca49d725322a79b05bc65d3))
|
||||
* remove begin_nested that breaks activate_purchase transaction ([0005d59](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/0005d59da1e58c38561c8346db89d8475a25d7df))
|
||||
* stars rate rounding + device/traffic purchase stats ([641ff86](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/641ff86bf6f1ac1f22146f4344beda05759869fc))
|
||||
* support prefix-based gift code lookup for activation ([4fb72ae](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/4fb72ae6e3bc65d93ab84b594b4ff5b4856c5357))
|
||||
|
||||
|
||||
### Refactoring
|
||||
|
||||
* deduplicate gift activation in start.py ([769d3a0](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/769d3a0b309fb6be1c3175cd66a0df7cb6e2fb67))
|
||||
* rename GIFTCODE_ start parameter prefix to GIFT_ ([42b6c80](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/42b6c80a48ad0100d5ddbbe99a096ebb7b292f08))
|
||||
|
||||
## [3.28.1](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/compare/v3.28.0...v3.28.1) (2026-03-10)
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* migrate pricing to days-based proration, fix promo revenue leaks, fix admin panel bugs ([fcdeff1](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/fcdeff1ee5155c88c634e12a703159c221d66af5))
|
||||
|
||||
## [3.28.0](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/compare/v3.27.0...v3.28.0) (2026-03-09)
|
||||
|
||||
|
||||
|
||||
+1
-1
@@ -19,7 +19,7 @@ RUN --mount=type=cache,target=/root/.cache/uv \
|
||||
|
||||
FROM python:3.13-slim
|
||||
|
||||
ARG VERSION="v3.28.0" # x-release-please-version
|
||||
ARG VERSION="v3.31.0" # x-release-please-version
|
||||
ARG BUILD_DATE
|
||||
ARG VCS_REF
|
||||
|
||||
|
||||
@@ -610,6 +610,16 @@ hooks.domain.com {
|
||||
}
|
||||
}
|
||||
|
||||
handle /riopay-webhook {
|
||||
reverse_proxy remnawave_bot:8080 {
|
||||
header_up Host {host}
|
||||
header_up X-Real-IP {remote_host}
|
||||
transport http {
|
||||
read_buffer 0
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
handle /remnawave-webhook {
|
||||
reverse_proxy remnawave_bot:8080 {
|
||||
header_up Host {host}
|
||||
@@ -827,6 +837,18 @@ http {
|
||||
proxy_buffering off;
|
||||
proxy_request_buffering off;
|
||||
}
|
||||
|
||||
location = /riopay-webhook {
|
||||
proxy_pass http://remnawave_bot_unified;
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
proxy_read_timeout 120s;
|
||||
proxy_send_timeout 120s;
|
||||
proxy_buffering off;
|
||||
proxy_request_buffering off;
|
||||
}
|
||||
|
||||
location = /remnawave-webhook {
|
||||
proxy_pass http://remnawave_bot_unified;
|
||||
@@ -1455,6 +1477,7 @@ CONTEST_BUTTON_VISIBLE=true
|
||||
- 💳 **WATA**
|
||||
- 💳 **Freekassa** (NSPK СБП + карты)
|
||||
- 💳 **CloudPayments** (карты + СБП)
|
||||
- 💳 **RioPay** (карты + СБП)
|
||||
- 🔥 Автогенерация счетов и webhook-уведомления
|
||||
- 💼 История операций
|
||||
- 🔄 Автоплатёж с настройкой дня списания
|
||||
|
||||
@@ -85,6 +85,7 @@ async def update_partner_settings(
|
||||
admin: User = Depends(require_permission('partners:settings')),
|
||||
):
|
||||
"""Update partner system settings."""
|
||||
import asyncio
|
||||
from pathlib import Path
|
||||
|
||||
# Update in-memory settings
|
||||
@@ -104,8 +105,8 @@ async def update_partner_settings(
|
||||
# Persist to .env file
|
||||
try:
|
||||
env_file = Path('.env')
|
||||
if env_file.exists():
|
||||
lines = env_file.read_text().splitlines()
|
||||
if await asyncio.to_thread(env_file.exists):
|
||||
lines = (await asyncio.to_thread(env_file.read_text)).splitlines()
|
||||
updates: dict[str, str] = {}
|
||||
|
||||
if request.withdrawal_enabled is not None:
|
||||
@@ -143,7 +144,7 @@ async def update_partner_settings(
|
||||
if key not in updated_keys:
|
||||
new_lines.append(f'{key}={value}')
|
||||
|
||||
env_file.write_text('\n'.join(new_lines) + '\n')
|
||||
await asyncio.to_thread(env_file.write_text, '\n'.join(new_lines) + '\n')
|
||||
logger.info('Updated partner settings in .env file', admin_id=admin.id)
|
||||
except Exception as e:
|
||||
logger.warning('Failed to update .env file', error=e)
|
||||
|
||||
@@ -210,7 +210,7 @@ def _is_checkable(record: PendingPayment) -> bool:
|
||||
if record.method == PaymentMethod.YOOKASSA:
|
||||
return status_str in {'pending', 'waiting_for_capture'}
|
||||
if record.method == PaymentMethod.CRYPTOBOT:
|
||||
return status_str in {'active'}
|
||||
return status_str == 'active'
|
||||
if record.method == PaymentMethod.CLOUDPAYMENTS:
|
||||
return status_str in {'pending', 'authorized'}
|
||||
if record.method == PaymentMethod.FREEKASSA:
|
||||
|
||||
@@ -489,44 +489,63 @@ async def admin_deactivate_discount_promocode(
|
||||
admin: User = Depends(require_permission('promocodes:edit')),
|
||||
db: AsyncSession = Depends(get_cabinet_db),
|
||||
) -> DeactivateDiscountResponse:
|
||||
"""Admin: deactivate a user's active discount promo code."""
|
||||
"""Admin: deactivate a user's active discount (promo code or promo offer)."""
|
||||
from app.database.crud.user import get_user_by_id as get_user
|
||||
|
||||
target_user = await get_user(db, user_id)
|
||||
if not target_user:
|
||||
raise HTTPException(status.HTTP_404_NOT_FOUND, 'User not found')
|
||||
|
||||
from app.services.promocode_service import PromoCodeService
|
||||
current_discount = getattr(target_user, 'promo_offer_discount_percent', 0) or 0
|
||||
source = getattr(target_user, 'promo_offer_discount_source', None)
|
||||
|
||||
service = PromoCodeService()
|
||||
result = await service.deactivate_discount_promocode(
|
||||
db=db,
|
||||
user_id=user_id,
|
||||
admin_initiated=True,
|
||||
)
|
||||
if current_discount <= 0:
|
||||
raise HTTPException(status.HTTP_400_BAD_REQUEST, 'User has no active discount')
|
||||
|
||||
if result['success']:
|
||||
return DeactivateDiscountResponse(
|
||||
success=True,
|
||||
message=f'Discount promo code deactivated for user {user_id}',
|
||||
deactivated_code=result.get('deactivated_code'),
|
||||
discount_percent=result.get('discount_percent', 0),
|
||||
# If source is a promo code, use the service to properly rollback usage
|
||||
if source and source.startswith('promocode:'):
|
||||
from app.services.promocode_service import PromoCodeService
|
||||
|
||||
service = PromoCodeService()
|
||||
result = await service.deactivate_discount_promocode(
|
||||
db=db,
|
||||
user_id=user_id,
|
||||
admin_initiated=True,
|
||||
)
|
||||
|
||||
error_messages = {
|
||||
'user_not_found': 'User not found',
|
||||
'no_active_discount_promocode': 'User has no active discount from a promo code',
|
||||
'discount_already_expired': 'Discount has already expired (cleaned up)',
|
||||
'server_error': 'Server error occurred',
|
||||
}
|
||||
if result['success']:
|
||||
return DeactivateDiscountResponse(
|
||||
success=True,
|
||||
message=f'Discount promo code deactivated for user {user_id}',
|
||||
deactivated_code=result.get('deactivated_code'),
|
||||
discount_percent=result.get('discount_percent', 0),
|
||||
user_id=user_id,
|
||||
)
|
||||
|
||||
error_code = result.get('error', 'server_error')
|
||||
error_message = error_messages.get(error_code, 'Failed to deactivate promo code')
|
||||
error_messages = {
|
||||
'user_not_found': 'User not found',
|
||||
'no_active_discount_promocode': 'User has no active discount from a promo code',
|
||||
'discount_already_expired': 'Discount has already expired (cleaned up)',
|
||||
'server_error': 'Server error occurred',
|
||||
}
|
||||
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail=error_message,
|
||||
error_code = result.get('error', 'server_error')
|
||||
raise HTTPException(status.HTTP_400_BAD_REQUEST, error_messages.get(error_code, 'Failed to deactivate'))
|
||||
|
||||
# For non-promocode offers (admin offers, etc.) — just clear the fields
|
||||
old_percent = target_user.promo_offer_discount_percent
|
||||
target_user.promo_offer_discount_percent = 0
|
||||
target_user.promo_offer_discount_source = None
|
||||
target_user.promo_offer_discount_expires_at = None
|
||||
target_user.updated_at = datetime.now(UTC)
|
||||
await db.commit()
|
||||
|
||||
return DeactivateDiscountResponse(
|
||||
success=True,
|
||||
message=f'Promo offer deactivated for user {user_id}',
|
||||
deactivated_code=None,
|
||||
discount_percent=old_percent,
|
||||
user_id=user_id,
|
||||
)
|
||||
|
||||
|
||||
|
||||
@@ -13,7 +13,7 @@ from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from app.database.crud.campaign import get_campaign_statistics, get_campaigns_count, get_campaigns_list
|
||||
from app.database.crud.server_squad import get_server_statistics
|
||||
from app.database.crud.subscription import get_subscriptions_statistics
|
||||
from app.database.crud.transaction import get_revenue_by_period, get_transactions_statistics
|
||||
from app.database.crud.transaction import REAL_PAYMENT_METHODS, get_revenue_by_period, get_transactions_statistics
|
||||
from app.database.models import (
|
||||
ReferralEarning,
|
||||
Subscription,
|
||||
@@ -931,6 +931,7 @@ async def get_recent_payments(
|
||||
Transaction.type == TransactionType.DEPOSIT.value,
|
||||
Transaction.is_completed == True,
|
||||
Transaction.created_at >= today_start,
|
||||
Transaction.payment_method.in_(REAL_PAYMENT_METHODS),
|
||||
)
|
||||
)
|
||||
)
|
||||
@@ -942,6 +943,7 @@ async def get_recent_payments(
|
||||
Transaction.type == TransactionType.DEPOSIT.value,
|
||||
Transaction.is_completed == True,
|
||||
Transaction.created_at >= week_ago,
|
||||
Transaction.payment_method.in_(REAL_PAYMENT_METHODS),
|
||||
)
|
||||
)
|
||||
)
|
||||
|
||||
@@ -1,9 +1,12 @@
|
||||
"""Admin routes for managing tariffs in cabinet."""
|
||||
|
||||
import asyncio
|
||||
|
||||
import structlog
|
||||
from fastapi import APIRouter, Depends, HTTPException, status
|
||||
from sqlalchemy import func, select
|
||||
from sqlalchemy import and_, func, select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from sqlalchemy.orm import joinedload
|
||||
|
||||
from app.database.crud.server_squad import get_all_server_squads
|
||||
from app.database.crud.tariff import (
|
||||
@@ -17,7 +20,7 @@ from app.database.crud.tariff import (
|
||||
set_tariff_promo_groups,
|
||||
update_tariff,
|
||||
)
|
||||
from app.database.models import PromoGroup, Subscription, Tariff, Transaction, TransactionType, User
|
||||
from app.database.models import PromoGroup, Subscription, SubscriptionStatus, Tariff, Transaction, TransactionType, User
|
||||
|
||||
from ..dependencies import get_cabinet_db, require_permission
|
||||
from ..schemas.tariffs import (
|
||||
@@ -26,6 +29,7 @@ from ..schemas.tariffs import (
|
||||
PromoGroupInfo,
|
||||
ServerInfo,
|
||||
ServerTrafficLimit,
|
||||
SyncSquadsResponse,
|
||||
TariffCreateRequest,
|
||||
TariffDetailResponse,
|
||||
TariffListItem,
|
||||
@@ -127,6 +131,7 @@ async def list_tariffs(
|
||||
is_daily=tariff.is_daily,
|
||||
daily_price_kopeks=tariff.daily_price_kopeks,
|
||||
allow_traffic_topup=tariff.allow_traffic_topup,
|
||||
show_in_gift=tariff.show_in_gift,
|
||||
traffic_limit_gb=tariff.traffic_limit_gb,
|
||||
device_limit=tariff.device_limit,
|
||||
tier_level=tariff.tier_level,
|
||||
@@ -265,6 +270,8 @@ async def get_tariff(
|
||||
traffic_reset_mode=tariff.traffic_reset_mode,
|
||||
# Внешний сквад
|
||||
external_squad_uuid=tariff.external_squad_uuid,
|
||||
# Показывать в подарках
|
||||
show_in_gift=tariff.show_in_gift,
|
||||
created_at=tariff.created_at,
|
||||
updated_at=tariff.updated_at,
|
||||
)
|
||||
@@ -303,7 +310,7 @@ async def create_new_tariff(
|
||||
period_prices=period_prices_dict,
|
||||
allowed_squads=request.allowed_squads,
|
||||
server_traffic_limits=server_limits_dict,
|
||||
promo_group_ids=request.promo_group_ids if request.promo_group_ids else None,
|
||||
promo_group_ids=request.promo_group_ids or None,
|
||||
# Произвольное количество дней
|
||||
custom_days_enabled=request.custom_days_enabled,
|
||||
price_per_day_kopeks=request.price_per_day_kopeks,
|
||||
@@ -321,6 +328,8 @@ async def create_new_tariff(
|
||||
traffic_reset_mode=request.traffic_reset_mode,
|
||||
# Внешний сквад
|
||||
external_squad_uuid=request.external_squad_uuid,
|
||||
# Показывать в подарках
|
||||
show_in_gift=request.show_in_gift,
|
||||
)
|
||||
|
||||
logger.info('Admin created tariff', admin_id=admin.id, tariff_id=tariff.id, tariff_name=tariff.name)
|
||||
@@ -347,6 +356,10 @@ async def update_existing_tariff(
|
||||
detail='Tariff not found',
|
||||
)
|
||||
|
||||
# Capture old values for change detection
|
||||
old_squads = list(tariff.allowed_squads) if tariff.allowed_squads else []
|
||||
old_external_squad = tariff.external_squad_uuid
|
||||
|
||||
# Build updates dict
|
||||
updates = {}
|
||||
if request.name is not None:
|
||||
@@ -413,6 +426,9 @@ async def update_existing_tariff(
|
||||
# Внешний сквад (None допускается для сброса)
|
||||
if 'external_squad_uuid' in request.model_fields_set:
|
||||
updates['external_squad_uuid'] = request.external_squad_uuid
|
||||
# Показывать в подарках
|
||||
if request.show_in_gift is not None:
|
||||
updates['show_in_gift'] = request.show_in_gift
|
||||
|
||||
if updates:
|
||||
await update_tariff(db, tariff, **updates)
|
||||
@@ -426,6 +442,18 @@ async def update_existing_tariff(
|
||||
# Перезагружаем периоды из БД для синхронизации с ботом
|
||||
await load_period_prices_from_db(db)
|
||||
|
||||
# Auto-sync squads to active subscriptions in Remnawave when squads changed
|
||||
new_squads = tariff.allowed_squads or []
|
||||
squads_changed = request.allowed_squads is not None and sorted(old_squads) != sorted(new_squads)
|
||||
ext_squad_changed = (
|
||||
'external_squad_uuid' in request.model_fields_set and tariff.external_squad_uuid != old_external_squad
|
||||
)
|
||||
if squads_changed or ext_squad_changed:
|
||||
asyncio.create_task(
|
||||
_background_sync_squads(tariff_id, admin.id),
|
||||
name=f'sync-squads-tariff-{tariff_id}',
|
||||
)
|
||||
|
||||
return await get_tariff(tariff_id, admin, db)
|
||||
|
||||
|
||||
@@ -586,3 +614,217 @@ async def get_tariff_stats(
|
||||
revenue_kopeks=revenue_kopeks,
|
||||
revenue_rubles=revenue_kopeks / 100,
|
||||
)
|
||||
|
||||
|
||||
async def _background_sync_squads(tariff_id: int, admin_id: int) -> None:
|
||||
"""Run squad sync in background with its own DB session (fire-and-forget)."""
|
||||
from app.database.database import AsyncSessionLocal
|
||||
from app.services.remnawave_service import RemnaWaveService
|
||||
|
||||
try:
|
||||
async with AsyncSessionLocal() as db:
|
||||
tariff = await get_tariff_by_id(db, tariff_id)
|
||||
if not tariff:
|
||||
return
|
||||
|
||||
result = await db.execute(
|
||||
select(Subscription)
|
||||
.join(User, Subscription.user_id == User.id)
|
||||
.options(joinedload(Subscription.user))
|
||||
.where(
|
||||
and_(
|
||||
Subscription.tariff_id == tariff_id,
|
||||
Subscription.status.in_([SubscriptionStatus.ACTIVE.value, SubscriptionStatus.TRIAL.value]),
|
||||
User.remnawave_uuid.isnot(None),
|
||||
)
|
||||
)
|
||||
)
|
||||
subscriptions = list(result.unique().scalars().all())
|
||||
|
||||
if not subscriptions:
|
||||
return
|
||||
|
||||
new_squads = tariff.allowed_squads or []
|
||||
ext_squad_uuid = tariff.external_squad_uuid
|
||||
|
||||
service = RemnaWaveService()
|
||||
updated = 0
|
||||
failed = 0
|
||||
|
||||
async with service.get_api_client() as api:
|
||||
semaphore = asyncio.Semaphore(5)
|
||||
|
||||
async def _sync_one(sub: Subscription) -> None:
|
||||
nonlocal updated, failed
|
||||
remnawave_uuid = sub.user.remnawave_uuid if sub.user else None
|
||||
if not remnawave_uuid:
|
||||
return
|
||||
async with semaphore:
|
||||
try:
|
||||
await api.update_user(
|
||||
uuid=remnawave_uuid,
|
||||
active_internal_squads=new_squads,
|
||||
external_squad_uuid=ext_squad_uuid,
|
||||
)
|
||||
sub.connected_squads = new_squads
|
||||
updated += 1
|
||||
except Exception as e:
|
||||
failed += 1
|
||||
logger.warning(
|
||||
'Background sync: failed to sync squads for user',
|
||||
user_id=sub.user_id,
|
||||
error=str(e),
|
||||
)
|
||||
|
||||
await asyncio.gather(*[_sync_one(sub) for sub in subscriptions])
|
||||
|
||||
await db.commit()
|
||||
logger.info(
|
||||
'Background squad sync completed after tariff update',
|
||||
admin_id=admin_id,
|
||||
tariff_id=tariff_id,
|
||||
tariff_name=tariff.name,
|
||||
total=len(subscriptions),
|
||||
updated=updated,
|
||||
failed=failed,
|
||||
)
|
||||
except Exception:
|
||||
logger.exception('Background squad sync failed', tariff_id=tariff_id)
|
||||
|
||||
|
||||
_SYNC_SQUADS_CONCURRENCY = 5
|
||||
_SYNC_SQUADS_MAX_CONSECUTIVE_FAILURES = 10
|
||||
|
||||
|
||||
@router.post('/{tariff_id}/sync-squads', response_model=SyncSquadsResponse)
|
||||
async def sync_tariff_squads(
|
||||
tariff_id: int,
|
||||
admin: User = Depends(require_permission('tariffs:edit')),
|
||||
db: AsyncSession = Depends(get_cabinet_db),
|
||||
):
|
||||
"""Sync squads from tariff to all active/trial subscriptions in Remnawave panel.
|
||||
|
||||
Updates connected_squads and external_squad_uuid for every active or trial
|
||||
subscription linked to this tariff. Only users that have a remnawave_uuid
|
||||
(i.e. already exist in the panel) are touched.
|
||||
"""
|
||||
tariff = await get_tariff_by_id(db, tariff_id)
|
||||
if not tariff:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail='Tariff not found',
|
||||
)
|
||||
|
||||
# Fetch active + trial subscriptions for this tariff whose users exist in Remnawave
|
||||
result = await db.execute(
|
||||
select(Subscription)
|
||||
.join(User, Subscription.user_id == User.id)
|
||||
.options(joinedload(Subscription.user))
|
||||
.where(
|
||||
and_(
|
||||
Subscription.tariff_id == tariff_id,
|
||||
Subscription.status.in_([SubscriptionStatus.ACTIVE.value, SubscriptionStatus.TRIAL.value]),
|
||||
User.remnawave_uuid.isnot(None),
|
||||
)
|
||||
)
|
||||
)
|
||||
subscriptions = list(result.unique().scalars().all())
|
||||
|
||||
if not subscriptions:
|
||||
return SyncSquadsResponse(
|
||||
tariff_id=tariff_id,
|
||||
tariff_name=tariff.name,
|
||||
total_subscriptions=0,
|
||||
updated_count=0,
|
||||
failed_count=0,
|
||||
skipped_count=0,
|
||||
)
|
||||
|
||||
new_squads = tariff.allowed_squads or []
|
||||
# None means "clear external squad" — intentional when tariff has none
|
||||
ext_squad_uuid = tariff.external_squad_uuid
|
||||
|
||||
# Sync to Remnawave panel with concurrency limit and circuit breaker
|
||||
from app.services.remnawave_service import RemnaWaveService
|
||||
|
||||
service = RemnaWaveService()
|
||||
updated_count = 0
|
||||
failed_count = 0
|
||||
skipped_count = 0
|
||||
consecutive_failures = 0
|
||||
errors: list[str] = []
|
||||
aborted = False
|
||||
|
||||
async with service.get_api_client() as api:
|
||||
semaphore = asyncio.Semaphore(_SYNC_SQUADS_CONCURRENCY)
|
||||
|
||||
async def _sync_one(sub: Subscription) -> str:
|
||||
# Counter mutations are safe: no `await` between read-modify-write
|
||||
# and the check within each branch (single-threaded asyncio event loop).
|
||||
nonlocal updated_count, failed_count, skipped_count, consecutive_failures, aborted
|
||||
|
||||
if aborted:
|
||||
skipped_count += 1
|
||||
return 'skipped'
|
||||
|
||||
remnawave_uuid = sub.user.remnawave_uuid if sub.user else None
|
||||
if not remnawave_uuid:
|
||||
skipped_count += 1
|
||||
return 'skipped'
|
||||
|
||||
async with semaphore:
|
||||
if aborted:
|
||||
skipped_count += 1
|
||||
return 'skipped'
|
||||
|
||||
try:
|
||||
await api.update_user(
|
||||
uuid=remnawave_uuid,
|
||||
active_internal_squads=new_squads,
|
||||
external_squad_uuid=ext_squad_uuid,
|
||||
)
|
||||
# Update local DB only on successful API call
|
||||
sub.connected_squads = new_squads
|
||||
updated_count += 1
|
||||
consecutive_failures = 0
|
||||
return 'ok'
|
||||
except Exception as e:
|
||||
failed_count += 1
|
||||
consecutive_failures += 1
|
||||
errors.append(f'user_id={sub.user_id}: sync failed')
|
||||
logger.warning(
|
||||
'Failed to sync squads for user in Remnawave',
|
||||
user_id=sub.user_id,
|
||||
remnawave_uuid=remnawave_uuid,
|
||||
error=str(e),
|
||||
)
|
||||
if consecutive_failures >= _SYNC_SQUADS_MAX_CONSECUTIVE_FAILURES:
|
||||
aborted = True
|
||||
errors.append(f'Aborted after {_SYNC_SQUADS_MAX_CONSECUTIVE_FAILURES} consecutive failures')
|
||||
return 'error'
|
||||
|
||||
await asyncio.gather(*[_sync_one(sub) for sub in subscriptions])
|
||||
|
||||
# Commit local DB changes only for successfully synced subscriptions
|
||||
await db.commit()
|
||||
|
||||
logger.info(
|
||||
'Admin synced squads for tariff',
|
||||
admin_id=admin.id,
|
||||
tariff_id=tariff_id,
|
||||
tariff_name=tariff.name,
|
||||
total=len(subscriptions),
|
||||
updated=updated_count,
|
||||
failed=failed_count,
|
||||
skipped=skipped_count,
|
||||
)
|
||||
|
||||
return SyncSquadsResponse(
|
||||
tariff_id=tariff_id,
|
||||
tariff_name=tariff.name,
|
||||
total_subscriptions=len(subscriptions),
|
||||
updated_count=updated_count,
|
||||
failed_count=failed_count,
|
||||
skipped_count=skipped_count,
|
||||
errors=errors[:20],
|
||||
)
|
||||
|
||||
@@ -246,6 +246,7 @@ async def update_ticket_settings(
|
||||
db: AsyncSession = Depends(get_cabinet_db),
|
||||
):
|
||||
"""Update ticket system settings."""
|
||||
import asyncio
|
||||
from pathlib import Path
|
||||
|
||||
from app.services.support_settings_service import SupportSettingsService
|
||||
@@ -280,8 +281,8 @@ async def update_ticket_settings(
|
||||
# Try to persist to .env file
|
||||
try:
|
||||
env_file = Path('.env')
|
||||
if env_file.exists():
|
||||
lines = env_file.read_text().splitlines()
|
||||
if await asyncio.to_thread(env_file.exists):
|
||||
lines = (await asyncio.to_thread(env_file.read_text)).splitlines()
|
||||
updates = {}
|
||||
|
||||
if request.sla_enabled is not None:
|
||||
@@ -314,7 +315,7 @@ async def update_ticket_settings(
|
||||
if key not in updated_keys:
|
||||
new_lines.append(f'{key}={value}')
|
||||
|
||||
env_file.write_text('\n'.join(new_lines) + '\n')
|
||||
await asyncio.to_thread(env_file.write_text, '\n'.join(new_lines) + '\n')
|
||||
logger.info('Updated ticket settings in .env file')
|
||||
except Exception as e:
|
||||
logger.warning('Failed to update .env file', error=e)
|
||||
|
||||
@@ -4,8 +4,9 @@ from datetime import UTC, datetime, timedelta
|
||||
|
||||
import structlog
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query, status
|
||||
from sqlalchemy import Integer, and_, func, or_, select
|
||||
from sqlalchemy import Integer, and_, delete as sa_delete, func, or_, select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from sqlalchemy.orm import selectinload
|
||||
|
||||
from app.database.crud.campaign import get_campaign_registration_by_user
|
||||
from app.database.crud.subscription import (
|
||||
@@ -24,7 +25,9 @@ from app.database.crud.user import (
|
||||
get_users_statistics,
|
||||
subtract_user_balance,
|
||||
)
|
||||
from app.database.crud.user_promo_group import sync_user_primary_promo_group
|
||||
from app.database.models import (
|
||||
GuestPurchase,
|
||||
PromoGroup,
|
||||
ReferralEarning,
|
||||
Subscription,
|
||||
@@ -34,12 +37,15 @@ from app.database.models import (
|
||||
Transaction,
|
||||
TransactionType,
|
||||
User,
|
||||
UserPromoGroup,
|
||||
UserStatus,
|
||||
)
|
||||
from app.utils.timezone import panel_datetime_to_utc
|
||||
|
||||
from ..dependencies import get_cabinet_db, require_permission
|
||||
from ..schemas.users import (
|
||||
AdminUserGiftItem,
|
||||
AdminUserGiftsResponse,
|
||||
DeleteDeviceResponse,
|
||||
DeleteUserRequest,
|
||||
DeleteUserResponse,
|
||||
@@ -610,14 +616,18 @@ async def get_user_detail(
|
||||
transactions_result = await db.execute(transactions_q)
|
||||
transactions = transactions_result.scalars().all()
|
||||
|
||||
_EXPENSE_TYPES = {TransactionType.WITHDRAWAL.value, TransactionType.SUBSCRIPTION_PAYMENT.value}
|
||||
_EXPENSE_TYPES = {
|
||||
TransactionType.WITHDRAWAL.value,
|
||||
TransactionType.SUBSCRIPTION_PAYMENT.value,
|
||||
TransactionType.GIFT_PAYMENT.value,
|
||||
}
|
||||
|
||||
recent_transactions = [
|
||||
UserTransactionItem(
|
||||
id=t.id,
|
||||
type=t.type,
|
||||
amount_kopeks=abs(t.amount_kopeks) if t.type in _EXPENSE_TYPES else t.amount_kopeks,
|
||||
amount_rubles=abs(t.amount_kopeks) / 100 if t.type in _EXPENSE_TYPES else t.amount_kopeks / 100,
|
||||
amount_kopeks=-abs(t.amount_kopeks) if t.type in _EXPENSE_TYPES else t.amount_kopeks,
|
||||
amount_rubles=-abs(t.amount_kopeks) / 100 if t.type in _EXPENSE_TYPES else t.amount_kopeks / 100,
|
||||
description=t.description,
|
||||
payment_method=t.payment_method,
|
||||
is_completed=t.is_completed,
|
||||
@@ -1018,10 +1028,10 @@ async def update_user_subscription(
|
||||
)
|
||||
|
||||
if request.action == 'extend':
|
||||
if not request.days:
|
||||
if not request.days or request.days <= 0:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail='Days parameter is required for extend action',
|
||||
detail='Days must be a positive integer',
|
||||
)
|
||||
|
||||
await extend_subscription(db, subscription, request.days)
|
||||
@@ -1040,6 +1050,36 @@ async def update_user_subscription(
|
||||
subscription=await _build_subscription_info_async(db, subscription),
|
||||
)
|
||||
|
||||
if request.action == 'shorten':
|
||||
if not request.days or request.days <= 0:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail='Days must be a positive integer',
|
||||
)
|
||||
|
||||
# Сокращение через отрицательный аргумент: extend_subscription(-N) уменьшает end_date
|
||||
await extend_subscription(db, subscription, -request.days)
|
||||
await db.refresh(subscription)
|
||||
|
||||
# Check if subscription expired after shortening
|
||||
if subscription.end_date <= datetime.now(UTC):
|
||||
subscription.status = SubscriptionStatus.EXPIRED.value
|
||||
await db.commit()
|
||||
await db.refresh(subscription)
|
||||
|
||||
# Sync to Remnawave panel
|
||||
await _sync_subscription_to_panel(db, user, subscription)
|
||||
|
||||
logger.info(
|
||||
'Admin shortened subscription for user by days', admin_id=admin.id, user_id=user_id, days=request.days
|
||||
)
|
||||
|
||||
return UpdateSubscriptionResponse(
|
||||
success=True,
|
||||
message=f'Subscription shortened by {request.days} days',
|
||||
subscription=await _build_subscription_info_async(db, subscription),
|
||||
)
|
||||
|
||||
if request.action == 'set_end_date':
|
||||
if not request.end_date:
|
||||
raise HTTPException(
|
||||
@@ -1081,13 +1121,38 @@ async def update_user_subscription(
|
||||
detail='Tariff not found',
|
||||
)
|
||||
|
||||
# Preserve extra purchased devices above the old tariff's base limit
|
||||
extra_devices = 0
|
||||
if subscription.tariff_id:
|
||||
old_tariff = await get_tariff_by_id(db, subscription.tariff_id)
|
||||
if old_tariff and old_tariff.device_limit:
|
||||
extra_devices = max(0, (subscription.device_limit or old_tariff.device_limit) - old_tariff.device_limit)
|
||||
|
||||
from app.config import settings
|
||||
|
||||
subscription.tariff_id = request.tariff_id
|
||||
subscription.traffic_limit_gb = tariff.traffic_limit_gb
|
||||
subscription.device_limit = tariff.device_limit
|
||||
|
||||
new_base = tariff.device_limit or 1
|
||||
new_total = new_base + extra_devices
|
||||
# Cap at new tariff's max_device_limit, falling back to global MAX_DEVICES_LIMIT
|
||||
effective_max = tariff.max_device_limit or (
|
||||
settings.MAX_DEVICES_LIMIT if settings.MAX_DEVICES_LIMIT > 0 else None
|
||||
)
|
||||
if effective_max and new_total > effective_max:
|
||||
new_total = effective_max
|
||||
subscription.device_limit = new_total
|
||||
# Set squads from tariff
|
||||
if tariff.allowed_squads:
|
||||
subscription.connected_squads = tariff.allowed_squads
|
||||
|
||||
# Convert trial subscription to paid when switching to a non-trial tariff
|
||||
if subscription.is_trial and not tariff.is_trial_available:
|
||||
subscription.is_trial = False
|
||||
if subscription.end_date and subscription.end_date > datetime.now(UTC):
|
||||
subscription.status = SubscriptionStatus.ACTIVE.value
|
||||
logger.info('Converted trial subscription to paid', user_id=user_id, tariff_name=tariff.name)
|
||||
|
||||
# Сбрасываем докупленный трафик при смене тарифа
|
||||
from sqlalchemy import delete as sql_delete
|
||||
|
||||
@@ -1095,11 +1160,21 @@ async def update_user_subscription(
|
||||
subscription.purchased_traffic_gb = 0
|
||||
subscription.traffic_reset_at = None
|
||||
|
||||
from app.config import settings
|
||||
|
||||
if settings.RESET_TRAFFIC_ON_TARIFF_SWITCH:
|
||||
subscription.traffic_used_gb = 0.0
|
||||
|
||||
# Записываем транзакцию о смене тарифа
|
||||
from app.database.crud.transaction import create_transaction
|
||||
|
||||
await create_transaction(
|
||||
db=db,
|
||||
user_id=user.id,
|
||||
type=TransactionType.SUBSCRIPTION_PAYMENT,
|
||||
amount_kopeks=0,
|
||||
description=f"Смена тарифа администратором на '{tariff.name}'",
|
||||
commit=False,
|
||||
)
|
||||
|
||||
await db.commit()
|
||||
await db.refresh(subscription)
|
||||
|
||||
@@ -1211,7 +1286,7 @@ async def update_user_subscription(
|
||||
|
||||
await add_subscription_traffic(db, subscription, request.traffic_gb)
|
||||
|
||||
# Реактивируем подписку если она была DISABLED (например, после LIMITED в RemnaWave)
|
||||
# Реактивируем подписку если она была DISABLED/EXPIRED (например, после LIMITED/EXPIRED в RemnaWave)
|
||||
await reactivate_subscription(db, subscription)
|
||||
|
||||
await db.refresh(subscription)
|
||||
@@ -1219,6 +1294,13 @@ async def update_user_subscription(
|
||||
# Sync to Remnawave panel
|
||||
await _sync_subscription_to_panel(db, user, subscription)
|
||||
|
||||
# Явно включаем пользователя на панели (PATCH может не снять LIMITED-статус)
|
||||
if getattr(user, 'remnawave_uuid', None) and subscription.status == 'active':
|
||||
from app.services.subscription_service import SubscriptionService
|
||||
|
||||
subscription_service = SubscriptionService()
|
||||
await subscription_service.enable_remnawave_user(user.remnawave_uuid)
|
||||
|
||||
logger.info('Admin added traffic for user', admin_id=admin.id, traffic_gb=request.traffic_gb, user_id=user_id)
|
||||
|
||||
return UpdateSubscriptionResponse(
|
||||
@@ -1572,8 +1654,22 @@ async def update_user_promo_group(
|
||||
)
|
||||
promo_group_name = promo_group.name
|
||||
|
||||
user.promo_group_id = new_promo_group_id
|
||||
user.updated_at = datetime.now(UTC)
|
||||
# Update M2M table (authoritative source) — not just the legacy FK column.
|
||||
# Without this, sync_user_primary_promo_group overwrites the admin change
|
||||
# on the next transaction.
|
||||
await db.execute(sa_delete(UserPromoGroup).where(UserPromoGroup.user_id == user_id))
|
||||
|
||||
if new_promo_group_id is not None:
|
||||
db.add(
|
||||
UserPromoGroup(
|
||||
user_id=user_id,
|
||||
promo_group_id=new_promo_group_id,
|
||||
assigned_by='admin',
|
||||
)
|
||||
)
|
||||
|
||||
await db.flush()
|
||||
await sync_user_primary_promo_group(db, user_id)
|
||||
await db.commit()
|
||||
await db.refresh(user)
|
||||
|
||||
@@ -1964,21 +2060,6 @@ async def reset_user_subscription(
|
||||
panel_deactivated=False,
|
||||
)
|
||||
|
||||
from app.database.crud.subscription import is_active_paid_subscription
|
||||
|
||||
if is_active_paid_subscription(user.subscription):
|
||||
logger.info(
|
||||
'⏭️ Пропуск сброса подписки: у пользователя активная оплаченная подписка',
|
||||
user_id=user_id,
|
||||
remnawave_uuid=user.remnawave_uuid,
|
||||
)
|
||||
return ResetSubscriptionResponse(
|
||||
success=False,
|
||||
message='Cannot reset active paid subscription. Subscription is still active and paid.',
|
||||
subscription_deleted=False,
|
||||
panel_deactivated=False,
|
||||
)
|
||||
|
||||
# Deactivate in Remnawave panel if requested
|
||||
if request.deactivate_in_panel and user.remnawave_uuid:
|
||||
try:
|
||||
@@ -2163,14 +2244,18 @@ async def get_user_transactions(
|
||||
result = await db.execute(query)
|
||||
transactions = result.scalars().all()
|
||||
|
||||
_EXPENSE_TYPES = {TransactionType.WITHDRAWAL.value, TransactionType.SUBSCRIPTION_PAYMENT.value}
|
||||
_EXPENSE_TYPES = {
|
||||
TransactionType.WITHDRAWAL.value,
|
||||
TransactionType.SUBSCRIPTION_PAYMENT.value,
|
||||
TransactionType.GIFT_PAYMENT.value,
|
||||
}
|
||||
|
||||
items = [
|
||||
UserTransactionItem(
|
||||
id=t.id,
|
||||
type=t.type,
|
||||
amount_kopeks=abs(t.amount_kopeks) if t.type in _EXPENSE_TYPES else t.amount_kopeks,
|
||||
amount_rubles=abs(t.amount_kopeks) / 100 if t.type in _EXPENSE_TYPES else t.amount_kopeks / 100,
|
||||
amount_kopeks=-abs(t.amount_kopeks) if t.type in _EXPENSE_TYPES else t.amount_kopeks,
|
||||
amount_rubles=-abs(t.amount_kopeks) / 100 if t.type in _EXPENSE_TYPES else t.amount_kopeks / 100,
|
||||
description=t.description,
|
||||
payment_method=t.payment_method,
|
||||
is_completed=t.is_completed,
|
||||
@@ -2753,3 +2838,118 @@ async def sync_user_to_panel(
|
||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
detail=f'Sync error: {e!s}',
|
||||
)
|
||||
|
||||
|
||||
# === User Gifts ===
|
||||
|
||||
|
||||
@router.get('/{user_id}/gifts', response_model=AdminUserGiftsResponse)
|
||||
async def get_user_gifts(
|
||||
user_id: int,
|
||||
admin: User = Depends(require_permission('users:read')),
|
||||
db: AsyncSession = Depends(get_cabinet_db),
|
||||
) -> AdminUserGiftsResponse:
|
||||
"""Get all gift subscriptions sent and received by user."""
|
||||
from sqlalchemy.orm import noload
|
||||
|
||||
# Lightweight existence check (avoids eager-loading all User relationships)
|
||||
user_exists = await db.execute(select(User.id).where(User.id == user_id))
|
||||
if not user_exists.scalar_one_or_none():
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail='User not found')
|
||||
|
||||
# True totals via COUNT queries
|
||||
sent_total = (
|
||||
await db.execute(
|
||||
select(func.count(GuestPurchase.id)).where(
|
||||
GuestPurchase.buyer_user_id == user_id,
|
||||
GuestPurchase.is_gift.is_(True),
|
||||
)
|
||||
)
|
||||
).scalar() or 0
|
||||
|
||||
received_total = (
|
||||
await db.execute(
|
||||
select(func.count(GuestPurchase.id)).where(
|
||||
GuestPurchase.user_id == user_id,
|
||||
GuestPurchase.is_gift.is_(True),
|
||||
)
|
||||
)
|
||||
).scalar() or 0
|
||||
|
||||
# Sent gifts (user is buyer) — suppress unneeded relationships
|
||||
sent_result = await db.execute(
|
||||
select(GuestPurchase)
|
||||
.options(
|
||||
selectinload(GuestPurchase.tariff),
|
||||
selectinload(GuestPurchase.user),
|
||||
noload(GuestPurchase.buyer),
|
||||
noload(GuestPurchase.landing),
|
||||
)
|
||||
.where(
|
||||
GuestPurchase.buyer_user_id == user_id,
|
||||
GuestPurchase.is_gift.is_(True),
|
||||
)
|
||||
.order_by(GuestPurchase.created_at.desc())
|
||||
.limit(200)
|
||||
)
|
||||
sent_purchases = sent_result.scalars().all()
|
||||
|
||||
# Received gifts (user is recipient) — suppress unneeded relationships
|
||||
received_result = await db.execute(
|
||||
select(GuestPurchase)
|
||||
.options(
|
||||
selectinload(GuestPurchase.tariff),
|
||||
selectinload(GuestPurchase.buyer),
|
||||
noload(GuestPurchase.user),
|
||||
noload(GuestPurchase.landing),
|
||||
)
|
||||
.where(
|
||||
GuestPurchase.user_id == user_id,
|
||||
GuestPurchase.is_gift.is_(True),
|
||||
)
|
||||
.order_by(GuestPurchase.created_at.desc())
|
||||
.limit(200)
|
||||
)
|
||||
received_purchases = received_result.scalars().all()
|
||||
|
||||
sent_items = [_build_gift_item(p, receiver=p.user) for p in sent_purchases]
|
||||
received_items = [_build_gift_item(p, buyer=p.buyer) for p in received_purchases]
|
||||
|
||||
return AdminUserGiftsResponse(
|
||||
sent=sent_items,
|
||||
received=received_items,
|
||||
sent_total=sent_total,
|
||||
received_total=received_total,
|
||||
)
|
||||
|
||||
|
||||
def _build_gift_item(
|
||||
p: GuestPurchase,
|
||||
receiver: User | None = None,
|
||||
buyer: User | None = None,
|
||||
) -> AdminUserGiftItem:
|
||||
"""Build an admin gift item from a GuestPurchase."""
|
||||
tariff_name = p.tariff.name if p.tariff else None
|
||||
device_limit = p.tariff.device_limit if p.tariff else 1
|
||||
return AdminUserGiftItem(
|
||||
id=p.id,
|
||||
token=p.token[:12],
|
||||
status=p.status,
|
||||
tariff_name=tariff_name,
|
||||
period_days=p.period_days,
|
||||
device_limit=device_limit,
|
||||
amount_kopeks=p.amount_kopeks,
|
||||
payment_method=p.payment_method,
|
||||
gift_recipient_type=p.gift_recipient_type,
|
||||
gift_recipient_value=p.gift_recipient_value,
|
||||
gift_message=p.gift_message,
|
||||
buyer_user_id=p.buyer_user_id,
|
||||
buyer_username=buyer.username if buyer else None,
|
||||
buyer_full_name=buyer.full_name if buyer else None,
|
||||
receiver_user_id=p.user_id,
|
||||
receiver_username=receiver.username if receiver else None,
|
||||
receiver_full_name=receiver.full_name if receiver else None,
|
||||
created_at=p.created_at,
|
||||
paid_at=p.paid_at,
|
||||
delivered_at=p.delivered_at,
|
||||
)
|
||||
|
||||
@@ -774,7 +774,7 @@ async def register_email(
|
||||
},
|
||||
db=db,
|
||||
)
|
||||
custom_subject, custom_body = override if override else (None, None)
|
||||
custom_subject, custom_body = override or (None, None)
|
||||
|
||||
await asyncio.to_thread(
|
||||
email_service.send_verification_email,
|
||||
@@ -911,7 +911,7 @@ async def register_email_standalone(
|
||||
},
|
||||
db=db,
|
||||
)
|
||||
custom_subject, custom_body = override if override else (None, None)
|
||||
custom_subject, custom_body = override or (None, None)
|
||||
|
||||
await asyncio.to_thread(
|
||||
email_service.send_verification_email,
|
||||
@@ -1049,7 +1049,7 @@ async def resend_verification(
|
||||
},
|
||||
db=db,
|
||||
)
|
||||
custom_subject, custom_body = override if override else (None, None)
|
||||
custom_subject, custom_body = override or (None, None)
|
||||
|
||||
await asyncio.to_thread(
|
||||
email_service.send_verification_email,
|
||||
@@ -1356,7 +1356,7 @@ async def forgot_password(
|
||||
context={'username': user.first_name or '', 'reset_url': full_url, 'expire_hours': str(expire_hours)},
|
||||
db=db,
|
||||
)
|
||||
custom_subject, custom_body = override if override else (None, None)
|
||||
custom_subject, custom_body = override or (None, None)
|
||||
|
||||
await asyncio.to_thread(
|
||||
email_service.send_password_reset_email,
|
||||
@@ -1524,7 +1524,7 @@ async def request_email_change(
|
||||
},
|
||||
db=db,
|
||||
)
|
||||
custom_subject, custom_body = override if override else (None, None)
|
||||
custom_subject, custom_body = override or (None, None)
|
||||
|
||||
try:
|
||||
await asyncio.to_thread(
|
||||
@@ -1579,7 +1579,7 @@ async def request_email_change(
|
||||
},
|
||||
db=db,
|
||||
)
|
||||
custom_subject, custom_body = override if override else (None, None)
|
||||
custom_subject, custom_body = override or (None, None)
|
||||
|
||||
await asyncio.to_thread(
|
||||
email_service.send_email_change_code,
|
||||
|
||||
@@ -14,6 +14,10 @@ from sqlalchemy import desc, func, select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.config import settings
|
||||
from app.database.crud.saved_payment_method import (
|
||||
deactivate_payment_method,
|
||||
get_active_payment_methods_by_user,
|
||||
)
|
||||
from app.database.crud.user import get_user_by_id
|
||||
from app.database.models import PaymentMethod, Transaction, User
|
||||
from app.services.payment_method_config_service import get_enabled_methods_for_user
|
||||
@@ -35,6 +39,8 @@ from ..schemas.balance import (
|
||||
PaymentMethodResponse,
|
||||
PendingPaymentListResponse,
|
||||
PendingPaymentResponse,
|
||||
SavedCardResponse,
|
||||
SavedCardsListResponse,
|
||||
StarsInvoiceRequest,
|
||||
StarsInvoiceResponse,
|
||||
TopUpRequest,
|
||||
@@ -102,8 +108,8 @@ async def get_transactions(
|
||||
for t in transactions:
|
||||
# Determine sign based on transaction type
|
||||
# Credits (positive): DEPOSIT, REFERRAL_REWARD, REFUND, POLL_REWARD
|
||||
# Debits (negative): SUBSCRIPTION_PAYMENT, WITHDRAWAL
|
||||
is_debit = t.type in ['subscription_payment', 'withdrawal']
|
||||
# Debits (negative): SUBSCRIPTION_PAYMENT, WITHDRAWAL, GIFT_PAYMENT
|
||||
is_debit = t.type in ['subscription_payment', 'withdrawal', 'gift_payment']
|
||||
amount_kopeks = -abs(t.amount_kopeks) if is_debit else abs(t.amount_kopeks)
|
||||
|
||||
items.append(
|
||||
@@ -198,7 +204,7 @@ async def get_payment_methods(
|
||||
'description': description,
|
||||
}
|
||||
)
|
||||
options = formatted_options if formatted_options else None
|
||||
options = formatted_options or None
|
||||
|
||||
methods.append(
|
||||
PaymentMethodResponse(
|
||||
@@ -244,13 +250,16 @@ async def create_stars_invoice(
|
||||
detail='Maximum amount is 10,000.00 RUB',
|
||||
)
|
||||
|
||||
# Calculate Stars amount
|
||||
# Calculate Stars amount and normalize kopeks to match exact star value
|
||||
try:
|
||||
amount_rubles = request.amount_kopeks / 100
|
||||
stars_amount = settings.rubles_to_stars(amount_rubles)
|
||||
|
||||
if stars_amount <= 0:
|
||||
stars_amount = 1
|
||||
|
||||
# Normalize kopeks so credited amount = stars * rate (no rounding mismatch)
|
||||
normalized_kopeks = round(stars_amount * settings.get_stars_rate() * 100)
|
||||
except Exception as e:
|
||||
logger.error('Error calculating Stars amount', error=e)
|
||||
raise HTTPException(
|
||||
@@ -259,7 +268,7 @@ async def create_stars_invoice(
|
||||
)
|
||||
|
||||
# Create payload for tracking payment
|
||||
payload = f'balance_topup_{user.id}_{request.amount_kopeks}_{int(time.time())}'
|
||||
payload = f'balance_topup_{user.id}_{normalized_kopeks}_{int(time.time())}'
|
||||
|
||||
# Create invoice through Telegram Bot API
|
||||
try:
|
||||
@@ -271,7 +280,7 @@ async def create_stars_invoice(
|
||||
api_url,
|
||||
json={
|
||||
'title': 'Пополнение баланса VPN',
|
||||
'description': f'Пополнение баланса на {amount_rubles:.2f} ₽ ({stars_amount} ⭐)',
|
||||
'description': f'Пополнение баланса на {normalized_kopeks / 100:.2f} ₽ ({stars_amount} ⭐)',
|
||||
'payload': payload,
|
||||
'provider_token': '', # Empty for Stars
|
||||
'currency': 'XTR',
|
||||
@@ -299,7 +308,7 @@ async def create_stars_invoice(
|
||||
return StarsInvoiceResponse(
|
||||
invoice_url=invoice_url,
|
||||
stars_amount=stars_amount,
|
||||
amount_kopeks=request.amount_kopeks,
|
||||
amount_kopeks=normalized_kopeks,
|
||||
)
|
||||
|
||||
except httpx.HTTPError as e:
|
||||
@@ -885,7 +894,7 @@ def _is_checkable(record: PendingPayment) -> bool:
|
||||
if record.method == PaymentMethod.YOOKASSA:
|
||||
return status in {'pending', 'waiting_for_capture'}
|
||||
if record.method == PaymentMethod.CRYPTOBOT:
|
||||
return status in {'active'}
|
||||
return status == 'active'
|
||||
if record.method == PaymentMethod.CLOUDPAYMENTS:
|
||||
return status in {'pending', 'authorized'}
|
||||
if record.method == PaymentMethod.FREEKASSA:
|
||||
@@ -1171,3 +1180,55 @@ async def check_payment_status(
|
||||
old_status=old_status,
|
||||
new_status=updated.status,
|
||||
)
|
||||
|
||||
|
||||
@router.get('/saved-cards', response_model=SavedCardsListResponse)
|
||||
async def get_saved_cards(
|
||||
user: User = Depends(get_current_cabinet_user),
|
||||
db: AsyncSession = Depends(get_cabinet_db),
|
||||
):
|
||||
"""Get user's saved payment methods (cards) for recurrent payments."""
|
||||
recurrent_enabled = settings.YOOKASSA_RECURRENT_ENABLED
|
||||
|
||||
if not recurrent_enabled:
|
||||
return SavedCardsListResponse(cards=[], recurrent_enabled=False)
|
||||
|
||||
methods = await get_active_payment_methods_by_user(db, user.id)
|
||||
|
||||
cards = [
|
||||
SavedCardResponse(
|
||||
id=m.id,
|
||||
method_type=m.method_type,
|
||||
card_last4=m.card_last4,
|
||||
card_type=m.card_type,
|
||||
title=m.title,
|
||||
created_at=m.created_at,
|
||||
)
|
||||
for m in methods
|
||||
]
|
||||
|
||||
return SavedCardsListResponse(cards=cards, recurrent_enabled=True)
|
||||
|
||||
|
||||
@router.delete('/saved-cards/{card_id}', status_code=status.HTTP_200_OK)
|
||||
async def delete_saved_card(
|
||||
card_id: int,
|
||||
user: User = Depends(get_current_cabinet_user),
|
||||
db: AsyncSession = Depends(get_cabinet_db),
|
||||
):
|
||||
"""Unlink (deactivate) a saved payment method."""
|
||||
if not settings.YOOKASSA_RECURRENT_ENABLED:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail='Recurrent payments are not enabled',
|
||||
)
|
||||
|
||||
success = await deactivate_payment_method(db, card_id, user.id)
|
||||
|
||||
if not success:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail='Saved card not found',
|
||||
)
|
||||
|
||||
return {'success': True, 'message': 'Card unlinked successfully'}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
"""Branding routes for cabinet - logo, project name, and theme colors management."""
|
||||
|
||||
import asyncio
|
||||
import json
|
||||
import os
|
||||
from pathlib import Path
|
||||
@@ -401,7 +402,7 @@ async def get_logo():
|
||||
"""
|
||||
logo_path = get_logo_path()
|
||||
|
||||
if logo_path is None or not logo_path.exists():
|
||||
if logo_path is None or not await asyncio.to_thread(logo_path.exists):
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail='No custom logo set')
|
||||
|
||||
# Determine media type from file extension
|
||||
@@ -470,7 +471,7 @@ async def upload_logo(
|
||||
)
|
||||
|
||||
# Ensure directory exists
|
||||
ensure_branding_dir()
|
||||
await asyncio.to_thread(ensure_branding_dir)
|
||||
|
||||
# Determine file extension from content type
|
||||
ext_map = {
|
||||
@@ -483,12 +484,12 @@ async def upload_logo(
|
||||
extension = ext_map.get(file.content_type, '.png')
|
||||
|
||||
# Remove old logo files with any extension
|
||||
for old_file in BRANDING_DIR.glob('logo.*'):
|
||||
old_file.unlink()
|
||||
for old_file in await asyncio.to_thread(lambda: list(BRANDING_DIR.glob('logo.*'))):
|
||||
await asyncio.to_thread(old_file.unlink)
|
||||
|
||||
# Save new logo
|
||||
logo_path = BRANDING_DIR / f'logo{extension}'
|
||||
logo_path.write_bytes(content)
|
||||
await asyncio.to_thread(logo_path.write_bytes, content)
|
||||
|
||||
# Mark that we have a custom logo
|
||||
await set_setting_value(db, BRANDING_LOGO_KEY, 'custom')
|
||||
@@ -517,8 +518,8 @@ async def delete_logo(
|
||||
):
|
||||
"""Delete custom logo and revert to letter. Admin only."""
|
||||
# Remove logo files
|
||||
for old_file in BRANDING_DIR.glob('logo.*'):
|
||||
old_file.unlink()
|
||||
for old_file in await asyncio.to_thread(lambda: list(BRANDING_DIR.glob('logo.*'))):
|
||||
await asyncio.to_thread(old_file.unlink)
|
||||
|
||||
# Update setting
|
||||
await set_setting_value(db, BRANDING_LOGO_KEY, 'default')
|
||||
|
||||
+373
-52
@@ -8,14 +8,22 @@ import structlog
|
||||
from fastapi import APIRouter, Depends, HTTPException, status
|
||||
from sqlalchemy import func, select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from sqlalchemy.orm import selectinload
|
||||
|
||||
from app.config import settings
|
||||
from app.database.crud.landing import get_purchase_by_token
|
||||
from app.database.crud.system_setting import get_setting_value
|
||||
from app.database.crud.tariff import get_tariff_by_id
|
||||
from app.database.crud.transaction import create_transaction, emit_transaction_side_effects
|
||||
from app.database.crud.user import subtract_user_balance
|
||||
from app.database.models import GuestPurchase, GuestPurchaseStatus, PaymentMethod, Tariff, TransactionType, User
|
||||
from app.database.models import (
|
||||
GuestPurchase,
|
||||
GuestPurchaseStatus,
|
||||
PaymentMethod,
|
||||
Tariff,
|
||||
TransactionType,
|
||||
User,
|
||||
UserPromoGroup,
|
||||
)
|
||||
from app.services.guest_purchase_service import (
|
||||
GuestPurchaseError,
|
||||
create_purchase,
|
||||
@@ -23,9 +31,12 @@ from app.services.guest_purchase_service import (
|
||||
)
|
||||
from app.services.payment_method_config_service import get_enabled_methods_for_user
|
||||
from app.utils.cache import RateLimitCache
|
||||
from app.utils.promo_offer import get_user_active_promo_discount_percent
|
||||
|
||||
from ..dependencies import get_cabinet_db, get_current_cabinet_user
|
||||
from ..schemas.gift import (
|
||||
ActivateGiftRequest,
|
||||
ActivateGiftResponse,
|
||||
GiftConfigPaymentMethod,
|
||||
GiftConfigResponse,
|
||||
GiftConfigSubOption,
|
||||
@@ -35,6 +46,8 @@ from ..schemas.gift import (
|
||||
GiftPurchaseResponse,
|
||||
GiftPurchaseStatusResponse,
|
||||
PendingGiftResponse,
|
||||
ReceivedGiftResponse,
|
||||
SentGiftResponse,
|
||||
)
|
||||
|
||||
|
||||
@@ -69,25 +82,61 @@ async def get_gift_config(
|
||||
balance_kopeks=user.balance_kopeks,
|
||||
)
|
||||
|
||||
# Load active tariffs
|
||||
# Load active tariffs visible in gift section
|
||||
result = await db.execute(
|
||||
select(Tariff).where(Tariff.is_active.is_(True)).order_by(Tariff.display_order, Tariff.id)
|
||||
select(Tariff)
|
||||
.where(Tariff.is_active.is_(True), Tariff.show_in_gift.is_(True))
|
||||
.order_by(Tariff.display_order, Tariff.id)
|
||||
)
|
||||
tariffs_db = result.scalars().all()
|
||||
|
||||
# Get user's promo group for discount calculation
|
||||
promo_group = user.get_primary_promo_group() if hasattr(user, 'get_primary_promo_group') else None
|
||||
if promo_group is None:
|
||||
promo_group = getattr(user, 'promo_group', None)
|
||||
promo_group_name = promo_group.name if promo_group else None
|
||||
|
||||
# Get active promo offer discount
|
||||
promo_offer_discount_percent = get_user_active_promo_discount_percent(user)
|
||||
|
||||
tariffs: list[GiftConfigTariff] = []
|
||||
for tariff in tariffs_db:
|
||||
period_days_list = tariff.get_available_periods()
|
||||
periods: list[GiftConfigTariffPeriod] = []
|
||||
for days in period_days_list:
|
||||
price = tariff.get_price_for_period(days)
|
||||
if price is None:
|
||||
base_price = tariff.get_price_for_period(days)
|
||||
if base_price is None:
|
||||
continue
|
||||
|
||||
original_price = base_price
|
||||
price = base_price
|
||||
|
||||
# Apply promo group discount
|
||||
promo_group_discount = 0
|
||||
if promo_group:
|
||||
promo_group_discount = promo_group.get_discount_percent('period', days)
|
||||
if promo_group_discount > 0:
|
||||
price = int(price * (100 - promo_group_discount) / 100)
|
||||
|
||||
# Apply active promo offer discount (stacks on top)
|
||||
if promo_offer_discount_percent > 0:
|
||||
price = price - price * promo_offer_discount_percent // 100
|
||||
|
||||
# Ensure minimum price of 1 kopek after all discounts
|
||||
price = max(1, price)
|
||||
|
||||
# Calculate combined discount percent
|
||||
combined_discount = 0
|
||||
if original_price > 0 and original_price != price:
|
||||
combined_discount = int((original_price - price) * 100 / original_price)
|
||||
|
||||
periods.append(
|
||||
GiftConfigTariffPeriod(
|
||||
days=days,
|
||||
price_kopeks=price,
|
||||
price_label=settings.format_price(price),
|
||||
original_price_kopeks=original_price if combined_discount > 0 else None,
|
||||
discount_percent=combined_discount if combined_discount > 0 else None,
|
||||
)
|
||||
)
|
||||
if not periods:
|
||||
@@ -127,6 +176,11 @@ async def get_gift_config(
|
||||
payment_methods=payment_methods,
|
||||
balance_kopeks=user.balance_kopeks,
|
||||
currency_symbol=getattr(settings, 'CURRENCY_SYMBOL', '\u20bd'),
|
||||
promo_group_name=promo_group_name,
|
||||
active_discount_percent=promo_offer_discount_percent if promo_offer_discount_percent > 0 else None,
|
||||
active_discount_expires_at=(
|
||||
getattr(user, 'promo_offer_discount_expires_at', None) if promo_offer_discount_percent > 0 else None
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
@@ -156,36 +210,40 @@ async def create_gift_purchase(
|
||||
detail='Purchases are restricted for this account',
|
||||
)
|
||||
|
||||
# Validate recipient format
|
||||
if body.recipient_type == 'email' and not _EMAIL_RE.match(body.recipient_value):
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail='Invalid email format',
|
||||
)
|
||||
if body.recipient_type == 'telegram' and not _TELEGRAM_RE.match(body.recipient_value):
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail='Invalid Telegram username format',
|
||||
)
|
||||
# Recipient is optional — when omitted, buyer gets a code to share manually
|
||||
has_recipient = bool(body.recipient_type and body.recipient_value)
|
||||
|
||||
# Prevent self-gift
|
||||
if body.recipient_type == 'telegram':
|
||||
normalized_recipient = body.recipient_value.lstrip('@').lower()
|
||||
if user.username and user.username.lower() == normalized_recipient:
|
||||
if has_recipient:
|
||||
# Validate recipient format
|
||||
if body.recipient_type == 'email' and not _EMAIL_RE.match(body.recipient_value):
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail='Cannot gift to yourself',
|
||||
detail='Invalid email format',
|
||||
)
|
||||
elif body.recipient_type == 'email':
|
||||
if user.email and user.email.lower() == body.recipient_value.lower():
|
||||
if body.recipient_type == 'telegram' and not _TELEGRAM_RE.match(body.recipient_value):
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail='Cannot gift to yourself',
|
||||
detail='Invalid Telegram username format',
|
||||
)
|
||||
|
||||
# Prevent self-gift
|
||||
if body.recipient_type == 'telegram':
|
||||
normalized_recipient = body.recipient_value.lstrip('@').lower()
|
||||
if user.username and user.username.lower() == normalized_recipient:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail='Cannot gift to yourself',
|
||||
)
|
||||
elif body.recipient_type == 'email':
|
||||
if user.email and user.email.lower() == body.recipient_value.lower():
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail='Cannot gift to yourself',
|
||||
)
|
||||
|
||||
# Find tariff and validate period
|
||||
tariff = await get_tariff_by_id(db, body.tariff_id)
|
||||
if tariff is None or not tariff.is_active:
|
||||
if tariff is None or not tariff.is_active or not tariff.show_in_gift:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail='Tariff not found or inactive',
|
||||
@@ -198,6 +256,37 @@ async def create_gift_purchase(
|
||||
detail='Price is not configured for this period',
|
||||
)
|
||||
|
||||
# Lock user row to prevent concurrent promo offer double-spend
|
||||
locked_result = await db.execute(
|
||||
select(User)
|
||||
.options(
|
||||
selectinload(User.user_promo_groups).selectinload(UserPromoGroup.promo_group),
|
||||
selectinload(User.promo_group),
|
||||
)
|
||||
.where(User.id == user.id)
|
||||
.with_for_update()
|
||||
.execution_options(populate_existing=True)
|
||||
)
|
||||
user = locked_result.scalar_one()
|
||||
|
||||
# Apply promo group discount
|
||||
promo_group = user.get_primary_promo_group() if hasattr(user, 'get_primary_promo_group') else None
|
||||
if promo_group is None:
|
||||
promo_group = getattr(user, 'promo_group', None)
|
||||
|
||||
if promo_group:
|
||||
discount_percent = promo_group.get_discount_percent('period', body.period_days)
|
||||
if discount_percent > 0:
|
||||
price_kopeks = int(price_kopeks * (100 - discount_percent) / 100)
|
||||
|
||||
# Apply active promo offer discount (stacks)
|
||||
promo_offer_discount_percent = get_user_active_promo_discount_percent(user)
|
||||
if promo_offer_discount_percent > 0:
|
||||
price_kopeks = price_kopeks - price_kopeks * promo_offer_discount_percent // 100
|
||||
|
||||
# Ensure minimum price of 1 kopek after all discounts
|
||||
price_kopeks = max(1, price_kopeks)
|
||||
|
||||
# Determine buyer contact info
|
||||
if user.email:
|
||||
buyer_contact_type = 'email'
|
||||
@@ -210,11 +299,10 @@ async def create_gift_purchase(
|
||||
buyer_contact_value = f'id:{user.telegram_id or user.id}'
|
||||
|
||||
# Pre-check: try to resolve Telegram username — DB first, then Bot API.
|
||||
# Placed after validation gates to prevent zero-cost enumeration.
|
||||
# The resolved ID is passed to fulfill_purchase to avoid a duplicate API call.
|
||||
# Only relevant when a recipient is explicitly specified.
|
||||
recipient_warning: str | None = None
|
||||
pre_resolved_telegram_id: int | None = None
|
||||
if body.recipient_type == 'telegram':
|
||||
if has_recipient and body.recipient_type == 'telegram':
|
||||
tg_username = body.recipient_value.lstrip('@')
|
||||
normalized_username = tg_username.lower()
|
||||
|
||||
@@ -253,6 +341,18 @@ async def create_gift_purchase(
|
||||
detail='payment_method is required for gateway mode',
|
||||
)
|
||||
|
||||
purchase_kwargs: dict = (
|
||||
{
|
||||
'gift_recipient_type': body.recipient_type,
|
||||
'gift_recipient_value': body.recipient_value,
|
||||
'gift_message': body.gift_message,
|
||||
}
|
||||
if has_recipient
|
||||
else {
|
||||
'gift_message': body.gift_message,
|
||||
}
|
||||
)
|
||||
|
||||
try:
|
||||
purchase = await create_purchase(
|
||||
db,
|
||||
@@ -264,12 +364,10 @@ async def create_gift_purchase(
|
||||
contact_value=buyer_contact_value,
|
||||
payment_method=body.payment_method,
|
||||
is_gift=True,
|
||||
gift_recipient_type=body.recipient_type,
|
||||
gift_recipient_value=body.recipient_value,
|
||||
gift_message=body.gift_message,
|
||||
source='cabinet',
|
||||
buyer_user_id=user.id,
|
||||
commit=False,
|
||||
**purchase_kwargs,
|
||||
)
|
||||
except GuestPurchaseError as exc:
|
||||
raise HTTPException(status_code=exc.status_code, detail=exc.message) from exc
|
||||
@@ -280,11 +378,18 @@ async def create_gift_purchase(
|
||||
|
||||
# Build return URL for after payment
|
||||
cabinet_base = (settings.CABINET_URL or '').rstrip('/')
|
||||
return_url = f'{cabinet_base}/gift/result?token={purchase.token}'
|
||||
return_url = f'{cabinet_base}/gift/result?token={purchase.token[:12]}'
|
||||
|
||||
from app.services.payment_service import PaymentService
|
||||
|
||||
payment_service = PaymentService()
|
||||
# Stars payments need a Bot instance to create invoice links
|
||||
bot = None
|
||||
if body.payment_method == 'telegram_stars':
|
||||
from aiogram import Bot
|
||||
|
||||
bot = Bot(token=settings.BOT_TOKEN)
|
||||
|
||||
payment_service = PaymentService(bot=bot)
|
||||
payment_result = await payment_service.create_guest_payment(
|
||||
db=db,
|
||||
amount_kopeks=price_kopeks,
|
||||
@@ -314,12 +419,18 @@ async def create_gift_purchase(
|
||||
detail='Payment provider returned an invalid response',
|
||||
)
|
||||
|
||||
# Consume promo offer discount before committing gateway purchase
|
||||
if promo_offer_discount_percent > 0 and getattr(user, 'promo_offer_discount_percent', 0):
|
||||
user.promo_offer_discount_percent = 0
|
||||
user.promo_offer_discount_source = None
|
||||
user.promo_offer_discount_expires_at = None
|
||||
|
||||
await db.commit()
|
||||
await db.refresh(purchase)
|
||||
|
||||
return GiftPurchaseResponse(
|
||||
status='created',
|
||||
purchase_token=purchase.token,
|
||||
purchase_token=purchase.token[:12],
|
||||
payment_url=payment_url,
|
||||
warning=recipient_warning,
|
||||
)
|
||||
@@ -332,6 +443,18 @@ async def create_gift_purchase(
|
||||
)
|
||||
|
||||
# Create purchase record
|
||||
balance_purchase_kwargs: dict = (
|
||||
{
|
||||
'gift_recipient_type': body.recipient_type,
|
||||
'gift_recipient_value': body.recipient_value,
|
||||
'gift_message': body.gift_message,
|
||||
}
|
||||
if has_recipient
|
||||
else {
|
||||
'gift_message': body.gift_message,
|
||||
}
|
||||
)
|
||||
|
||||
try:
|
||||
purchase = await create_purchase(
|
||||
db,
|
||||
@@ -343,12 +466,10 @@ async def create_gift_purchase(
|
||||
contact_value=buyer_contact_value,
|
||||
payment_method='balance',
|
||||
is_gift=True,
|
||||
gift_recipient_type=body.recipient_type,
|
||||
gift_recipient_value=body.recipient_value,
|
||||
gift_message=body.gift_message,
|
||||
source='cabinet',
|
||||
buyer_user_id=user.id,
|
||||
commit=False,
|
||||
**balance_purchase_kwargs,
|
||||
)
|
||||
except GuestPurchaseError as exc:
|
||||
raise HTTPException(status_code=exc.status_code, detail=exc.message) from exc
|
||||
@@ -357,13 +478,14 @@ async def create_gift_purchase(
|
||||
if recipient_warning:
|
||||
purchase.recipient_warning = recipient_warning
|
||||
|
||||
# Subtract balance
|
||||
# Subtract balance (consume promo offer if one was applied)
|
||||
balance_ok = await subtract_user_balance(
|
||||
db,
|
||||
user,
|
||||
price_kopeks,
|
||||
description=f'Gift: {tariff.name} ({body.period_days}d)',
|
||||
create_transaction=False,
|
||||
consume_promo_offer=promo_offer_discount_percent > 0,
|
||||
)
|
||||
if not balance_ok:
|
||||
await db.rollback()
|
||||
@@ -372,13 +494,18 @@ async def create_gift_purchase(
|
||||
detail='Insufficient balance',
|
||||
)
|
||||
|
||||
# Transaction description: include recipient when specified
|
||||
tx_description = f'Gift: {tariff.name} ({body.period_days}d)'
|
||||
if has_recipient:
|
||||
tx_description += f' -> {body.recipient_value}'
|
||||
|
||||
# Create transaction record
|
||||
transaction = await create_transaction(
|
||||
db,
|
||||
user_id=user.id,
|
||||
type=TransactionType.GIFT_PAYMENT,
|
||||
amount_kopeks=price_kopeks,
|
||||
description=f'Gift: {tariff.name} ({body.period_days}d) -> {body.recipient_value}',
|
||||
description=tx_description,
|
||||
payment_method=PaymentMethod.BALANCE,
|
||||
commit=False,
|
||||
)
|
||||
@@ -397,24 +524,26 @@ async def create_gift_purchase(
|
||||
user_id=user.id,
|
||||
type=TransactionType.GIFT_PAYMENT,
|
||||
payment_method=PaymentMethod.BALANCE,
|
||||
description=f'Gift: {tariff.name} ({body.period_days}d) -> {body.recipient_value}',
|
||||
description=tx_description,
|
||||
)
|
||||
|
||||
# Capture token before fulfill_purchase — session state may change after rollback inside fulfill
|
||||
purchase_token = purchase.token
|
||||
|
||||
# Fulfill the purchase (find/create recipient user, create subscription, notify)
|
||||
try:
|
||||
await fulfill_purchase(db, purchase_token, pre_resolved_telegram_id=pre_resolved_telegram_id)
|
||||
except Exception:
|
||||
logger.exception(
|
||||
'Gift purchase fulfillment failed (purchase is paid, will retry)',
|
||||
purchase_id=purchase.id,
|
||||
)
|
||||
# Only fulfill immediately when a specific recipient was provided.
|
||||
# Code-only gifts (no recipient) stay in PAID status until someone activates via code.
|
||||
if has_recipient:
|
||||
try:
|
||||
await fulfill_purchase(db, purchase_token, pre_resolved_telegram_id=pre_resolved_telegram_id)
|
||||
except Exception:
|
||||
logger.exception(
|
||||
'Gift purchase fulfillment failed (purchase is paid, will retry)',
|
||||
purchase_id=purchase.id,
|
||||
)
|
||||
|
||||
return GiftPurchaseResponse(
|
||||
status='ok',
|
||||
purchase_token=purchase_token,
|
||||
purchase_token=purchase_token[:12],
|
||||
warning=recipient_warning,
|
||||
)
|
||||
|
||||
@@ -427,12 +556,14 @@ async def get_pending_gifts(
|
||||
"""Get pending gift purchases that the current user can activate."""
|
||||
result = await db.execute(
|
||||
select(GuestPurchase)
|
||||
.options(selectinload(GuestPurchase.tariff))
|
||||
.where(
|
||||
GuestPurchase.user_id == user.id,
|
||||
GuestPurchase.is_gift.is_(True),
|
||||
GuestPurchase.status == GuestPurchaseStatus.PENDING_ACTIVATION.value,
|
||||
)
|
||||
.order_by(GuestPurchase.created_at.desc())
|
||||
.limit(100)
|
||||
)
|
||||
purchases = result.scalars().all()
|
||||
|
||||
@@ -445,7 +576,7 @@ async def get_pending_gifts(
|
||||
|
||||
pending.append(
|
||||
PendingGiftResponse(
|
||||
token=p.token,
|
||||
token=p.token[:12],
|
||||
tariff_name=p.tariff.name if p.tariff else None,
|
||||
period_days=p.period_days,
|
||||
gift_message=p.gift_message,
|
||||
@@ -464,7 +595,13 @@ async def get_gift_purchase_status(
|
||||
db: AsyncSession = Depends(get_cabinet_db),
|
||||
):
|
||||
"""Get the status of a cabinet gift purchase."""
|
||||
purchase = await get_purchase_by_token(db, token)
|
||||
if len(token) >= 64:
|
||||
token_filter = GuestPurchase.token == token
|
||||
else:
|
||||
token_filter = GuestPurchase.token.startswith(token)
|
||||
|
||||
result = await db.execute(select(GuestPurchase).options(selectinload(GuestPurchase.tariff)).where(token_filter))
|
||||
purchase = result.scalars().first()
|
||||
if purchase is None:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
@@ -484,12 +621,196 @@ async def get_gift_purchase_status(
|
||||
if purchase.gift_recipient_value:
|
||||
recipient_contact_value = purchase.gift_recipient_value
|
||||
|
||||
is_code_only = purchase.is_gift and not purchase.gift_recipient_type
|
||||
|
||||
return GiftPurchaseStatusResponse(
|
||||
status=purchase.status,
|
||||
is_gift=True,
|
||||
is_code_only=is_code_only,
|
||||
purchase_token=purchase.token[:12] if is_code_only else None,
|
||||
recipient_contact_value=recipient_contact_value,
|
||||
gift_message=purchase.gift_message,
|
||||
tariff_name=tariff_name,
|
||||
period_days=purchase.period_days,
|
||||
warning=purchase.recipient_warning,
|
||||
)
|
||||
|
||||
|
||||
@router.get('/sent', response_model=list[SentGiftResponse])
|
||||
async def get_sent_gifts(
|
||||
user: User = Depends(get_current_cabinet_user),
|
||||
db: AsyncSession = Depends(get_cabinet_db),
|
||||
):
|
||||
"""Get all gifts the current user has sent."""
|
||||
result = await db.execute(
|
||||
select(GuestPurchase)
|
||||
.options(selectinload(GuestPurchase.tariff), selectinload(GuestPurchase.user))
|
||||
.where(
|
||||
GuestPurchase.buyer_user_id == user.id,
|
||||
GuestPurchase.is_gift.is_(True),
|
||||
)
|
||||
.order_by(GuestPurchase.created_at.desc())
|
||||
.limit(100)
|
||||
)
|
||||
purchases = result.scalars().all()
|
||||
|
||||
sent: list[SentGiftResponse] = []
|
||||
for p in purchases:
|
||||
activated_by_username = None
|
||||
if p.status == GuestPurchaseStatus.DELIVERED.value and p.user and p.user.username:
|
||||
activated_by_username = f'@{p.user.username}'
|
||||
|
||||
sent.append(
|
||||
SentGiftResponse(
|
||||
token=p.token[:12],
|
||||
tariff_name=p.tariff.name if p.tariff else None,
|
||||
period_days=p.period_days,
|
||||
device_limit=p.tariff.device_limit if p.tariff else 1,
|
||||
status=p.status,
|
||||
gift_recipient_value=p.gift_recipient_value,
|
||||
gift_message=p.gift_message,
|
||||
activated_by_username=activated_by_username,
|
||||
created_at=p.created_at,
|
||||
)
|
||||
)
|
||||
|
||||
return sent
|
||||
|
||||
|
||||
@router.get('/received', response_model=list[ReceivedGiftResponse])
|
||||
async def get_received_gifts(
|
||||
user: User = Depends(get_current_cabinet_user),
|
||||
db: AsyncSession = Depends(get_cabinet_db),
|
||||
):
|
||||
"""Get all gifts the current user has received."""
|
||||
result = await db.execute(
|
||||
select(GuestPurchase)
|
||||
.options(selectinload(GuestPurchase.tariff), selectinload(GuestPurchase.buyer))
|
||||
.where(
|
||||
GuestPurchase.user_id == user.id,
|
||||
GuestPurchase.is_gift.is_(True),
|
||||
)
|
||||
.order_by(GuestPurchase.created_at.desc())
|
||||
.limit(100)
|
||||
)
|
||||
purchases = result.scalars().all()
|
||||
|
||||
received: list[ReceivedGiftResponse] = []
|
||||
for p in purchases:
|
||||
sender_display = None
|
||||
if p.buyer and p.buyer.username:
|
||||
sender_display = f'@{p.buyer.username}'
|
||||
elif p.contact_value:
|
||||
sender_display = p.contact_value
|
||||
|
||||
received.append(
|
||||
ReceivedGiftResponse(
|
||||
token=p.token[:12],
|
||||
tariff_name=p.tariff.name if p.tariff else None,
|
||||
period_days=p.period_days,
|
||||
device_limit=p.tariff.device_limit if p.tariff else 1,
|
||||
status=p.status,
|
||||
sender_display=sender_display,
|
||||
gift_message=p.gift_message,
|
||||
created_at=p.created_at,
|
||||
)
|
||||
)
|
||||
|
||||
return received
|
||||
|
||||
|
||||
@router.post('/activate', response_model=ActivateGiftResponse)
|
||||
async def activate_gift_by_code(
|
||||
body: ActivateGiftRequest,
|
||||
user: User = Depends(get_current_cabinet_user),
|
||||
db: AsyncSession = Depends(get_cabinet_db),
|
||||
):
|
||||
"""Activate a gift subscription by its code (token)."""
|
||||
from app.services.guest_purchase_service import activate_purchase as svc_activate
|
||||
|
||||
# Bug 2 fix: rate limit activation attempts to prevent brute-force token enumeration
|
||||
is_limited = await RateLimitCache.is_rate_limited(user.id, 'gift_activate', limit=10, window=60)
|
||||
if is_limited:
|
||||
raise HTTPException(status_code=status.HTTP_429_TOO_MANY_REQUESTS, detail='Too many requests')
|
||||
|
||||
code = body.code.strip()
|
||||
if code.upper().startswith('GIFT-'):
|
||||
code = code[5:]
|
||||
|
||||
if len(code) < 8:
|
||||
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail='Code too short')
|
||||
|
||||
# Support both full token and prefix-based lookup (displayed codes are truncated)
|
||||
if len(code) >= 64:
|
||||
# Full token — exact match
|
||||
token_filter = GuestPurchase.token == code
|
||||
else:
|
||||
# Prefix match — for short display codes like GIFT-XXXXXXXXXXXX
|
||||
token_filter = GuestPurchase.token.startswith(code)
|
||||
|
||||
result = await db.execute(
|
||||
select(GuestPurchase)
|
||||
.options(selectinload(GuestPurchase.tariff))
|
||||
.where(token_filter, GuestPurchase.is_gift.is_(True))
|
||||
.with_for_update()
|
||||
)
|
||||
purchase = result.scalars().first()
|
||||
|
||||
if purchase is None or not purchase.is_gift:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail='Gift not found',
|
||||
)
|
||||
|
||||
# Bug 1 fix: check ownership BEFORE leaking any status/tariff info
|
||||
if purchase.user_id is not None and purchase.user_id != user.id:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail='Gift not found',
|
||||
)
|
||||
|
||||
# Prevent self-activation: buyer cannot activate their own gift
|
||||
if purchase.buyer_user_id is not None and purchase.buyer_user_id == user.id:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail='Cannot activate your own gift',
|
||||
)
|
||||
|
||||
if purchase.status == GuestPurchaseStatus.DELIVERED.value:
|
||||
return ActivateGiftResponse(
|
||||
status='activated',
|
||||
tariff_name=purchase.tariff.name if purchase.tariff else None,
|
||||
period_days=purchase.period_days,
|
||||
)
|
||||
|
||||
# Code-only gifts are in PAID status; directed gifts are in PENDING_ACTIVATION
|
||||
activatable_statuses = {
|
||||
GuestPurchaseStatus.PENDING_ACTIVATION.value,
|
||||
GuestPurchaseStatus.PAID.value,
|
||||
}
|
||||
if purchase.status not in activatable_statuses:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail='This gift cannot be activated',
|
||||
)
|
||||
|
||||
# For code-only gifts (user_id is None), link the purchase to the activating user
|
||||
if purchase.user_id is None:
|
||||
purchase.user_id = user.id
|
||||
|
||||
# Transition PAID → PENDING_ACTIVATION so activate_purchase() accepts it
|
||||
if purchase.status == GuestPurchaseStatus.PAID.value:
|
||||
purchase.status = GuestPurchaseStatus.PENDING_ACTIVATION.value
|
||||
|
||||
await db.flush()
|
||||
|
||||
try:
|
||||
await svc_activate(db, purchase.token, skip_notification=True)
|
||||
except GuestPurchaseError as exc:
|
||||
raise HTTPException(status_code=exc.status_code, detail=exc.message) from exc
|
||||
|
||||
return ActivateGiftResponse(
|
||||
status='activated',
|
||||
tariff_name=purchase.tariff.name if purchase.tariff else None,
|
||||
period_days=purchase.period_days,
|
||||
)
|
||||
|
||||
@@ -160,7 +160,7 @@ async def get_rules(
|
||||
db: AsyncSession = Depends(get_cabinet_db),
|
||||
):
|
||||
"""Get service rules - uses same function as bot."""
|
||||
requested_lang = language.split('-')[0].lower()
|
||||
requested_lang = language.split('-', maxsplit=1)[0].lower()
|
||||
|
||||
# Use the same function as bot to ensure consistent content
|
||||
content = await get_current_rules_content(db, requested_lang)
|
||||
|
||||
@@ -607,6 +607,13 @@ async def create_landing_purchase(
|
||||
except GuestPurchaseError as exc:
|
||||
raise HTTPException(status_code=exc.status_code, detail=exc.message) from exc
|
||||
|
||||
# Gift purchases require the tariff to be visible in the gift section
|
||||
if body.is_gift and not tariff.show_in_gift:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail='This tariff is not available for gift purchases',
|
||||
)
|
||||
|
||||
# Validate amount against per-method min/max limits (before creating purchase record)
|
||||
min_amount = method_config.get('min_amount_kopeks')
|
||||
max_amount = method_config.get('max_amount_kopeks')
|
||||
|
||||
@@ -92,8 +92,7 @@ async def get_referral_info(
|
||||
available_balance = min(user.balance_kopeks, referral_entitlement)
|
||||
|
||||
# Build referral link
|
||||
bot_username = settings.get_bot_username() or 'bot'
|
||||
referral_link = f'https://t.me/{bot_username}?start={user.referral_code}'
|
||||
referral_link = settings.get_referral_link(user.referral_code) if user.referral_code else ''
|
||||
|
||||
return ReferralInfoResponse(
|
||||
referral_code=user.referral_code or '',
|
||||
@@ -243,5 +242,6 @@ async def get_referral_terms():
|
||||
first_topup_bonus_rubles=settings.REFERRAL_FIRST_TOPUP_BONUS_KOPEKS / 100,
|
||||
inviter_bonus_kopeks=settings.REFERRAL_INVITER_BONUS_KOPEKS,
|
||||
inviter_bonus_rubles=settings.REFERRAL_INVITER_BONUS_KOPEKS / 100,
|
||||
max_commission_payments=settings.REFERRAL_MAX_COMMISSION_PAYMENTS,
|
||||
partner_section_visible=settings.REFERRAL_PARTNER_SECTION_VISIBLE,
|
||||
)
|
||||
|
||||
@@ -98,12 +98,13 @@ def _apply_addon_discount(
|
||||
|
||||
Returns dict with keys: discounted, discount, percent
|
||||
"""
|
||||
from app.utils.pricing_utils import apply_percentage_discount
|
||||
|
||||
percent = _get_addon_discount_percent(user, category, period_days)
|
||||
if percent <= 0 or amount <= 0:
|
||||
return {'discounted': amount, 'discount': 0, 'percent': 0}
|
||||
|
||||
discount_value = int(amount * percent / 100)
|
||||
discounted_amount = amount - discount_value
|
||||
discounted_amount, discount_value = apply_percentage_discount(amount, percent)
|
||||
return {
|
||||
'discounted': discounted_amount,
|
||||
'discount': discount_value,
|
||||
@@ -860,15 +861,15 @@ async def purchase_traffic(
|
||||
# Пропорциональный расчёт применяем только в классическом режиме.
|
||||
if is_tariff_mode:
|
||||
prorated_price = base_price_kopeks
|
||||
months_charged = 1
|
||||
days_charged = 30
|
||||
else:
|
||||
prorated_price, months_charged = calculate_prorated_price(
|
||||
prorated_price, days_charged = calculate_prorated_price(
|
||||
base_price_kopeks,
|
||||
subscription.end_date,
|
||||
)
|
||||
|
||||
# Apply discount from promo group using proper method
|
||||
period_hint_days = months_charged * 30 if months_charged > 0 else 30
|
||||
period_hint_days = days_charged if days_charged > 0 else 30
|
||||
discount_result = _apply_addon_discount(user, 'traffic', prorated_price, period_hint_days)
|
||||
final_price = discount_result['discounted']
|
||||
traffic_discount_percent = discount_result['percent']
|
||||
@@ -933,7 +934,7 @@ async def purchase_traffic(
|
||||
# Добавляем трафик (add_subscription_traffic обновляет purchased_traffic_gb, traffic_reset_at и коммитит)
|
||||
await add_subscription_traffic(db, subscription, request.gb)
|
||||
|
||||
# Реактивируем подписку если она была DISABLED (например, после LIMITED в RemnaWave)
|
||||
# Реактивируем подписку если она была DISABLED/EXPIRED (например, после LIMITED/EXPIRED в RemnaWave)
|
||||
from app.database.crud.subscription import reactivate_subscription
|
||||
|
||||
await reactivate_subscription(db, subscription)
|
||||
@@ -943,6 +944,9 @@ async def purchase_traffic(
|
||||
subscription_service = SubscriptionService()
|
||||
if getattr(user, 'remnawave_uuid', None):
|
||||
await subscription_service.update_remnawave_user(db, subscription)
|
||||
# Явно включаем пользователя на панели (PATCH может не снять LIMITED-статус)
|
||||
if subscription.status == 'active':
|
||||
await subscription_service.enable_remnawave_user(user.remnawave_uuid)
|
||||
else:
|
||||
await subscription_service.create_remnawave_user(db, subscription)
|
||||
except Exception as e:
|
||||
@@ -1134,6 +1138,7 @@ async def purchase_devices_legacy(
|
||||
description=description,
|
||||
create_transaction=True,
|
||||
payment_method=PaymentMethod.BALANCE,
|
||||
transaction_type=TransactionType.SUBSCRIPTION_PAYMENT,
|
||||
)
|
||||
if not success:
|
||||
raise HTTPException(
|
||||
@@ -1468,7 +1473,7 @@ async def activate_trial(
|
||||
duration_days=trial_duration,
|
||||
traffic_limit_gb=trial_traffic_limit,
|
||||
device_limit=trial_device_limit,
|
||||
connected_squads=trial_squads if trial_squads else None,
|
||||
connected_squads=trial_squads or None,
|
||||
tariff_id=tariff_id_for_trial,
|
||||
)
|
||||
|
||||
@@ -1903,7 +1908,7 @@ async def submit_purchase(
|
||||
period_days=selection.period.days,
|
||||
was_trial_conversion=result.get('was_trial_conversion', False),
|
||||
amount_kopeks=pricing.final_total,
|
||||
purchase_type='renewal' if not is_new_subscription else None,
|
||||
purchase_type='renewal' if not is_new_subscription else 'first_purchase',
|
||||
)
|
||||
finally:
|
||||
await bot.session.close()
|
||||
@@ -2356,7 +2361,7 @@ async def purchase_tariff(
|
||||
period_days=period_days,
|
||||
was_trial_conversion=False,
|
||||
amount_kopeks=price_kopeks,
|
||||
purchase_type='renewal' if not was_new_subscription else None,
|
||||
purchase_type='renewal' if not was_new_subscription else 'first_purchase',
|
||||
)
|
||||
finally:
|
||||
await bot.session.close()
|
||||
@@ -2522,6 +2527,7 @@ async def purchase_devices(
|
||||
description=description,
|
||||
create_transaction=True,
|
||||
payment_method=PaymentMethod.BALANCE,
|
||||
transaction_type=TransactionType.SUBSCRIPTION_PAYMENT,
|
||||
)
|
||||
if not success:
|
||||
raise HTTPException(
|
||||
@@ -2644,7 +2650,6 @@ async def save_traffic_cart(
|
||||
db: AsyncSession = Depends(get_cabinet_db),
|
||||
) -> dict[str, bool]:
|
||||
"""Save cart for traffic purchase (for insufficient balance flow)."""
|
||||
from app.utils.pricing_utils import calculate_prorated_price
|
||||
|
||||
await db.refresh(user, ['subscription'])
|
||||
subscription = user.subscription
|
||||
@@ -2715,26 +2720,18 @@ async def save_traffic_cart(
|
||||
)
|
||||
base_price_kopeks = matching_pkg['price']
|
||||
|
||||
# Apply promo group discount
|
||||
traffic_discount_percent = 0
|
||||
promo_group = (
|
||||
user.get_primary_promo_group()
|
||||
if hasattr(user, 'get_primary_promo_group')
|
||||
else getattr(user, 'promo_group', None)
|
||||
)
|
||||
if promo_group:
|
||||
apply_to_addons = getattr(promo_group, 'apply_discounts_to_addons', True)
|
||||
if apply_to_addons:
|
||||
traffic_discount_percent = max(0, min(100, int(getattr(promo_group, 'traffic_discount_percent', 0) or 0)))
|
||||
# Calculate prorated price (days-based), then apply discount
|
||||
from app.utils.pricing_utils import calculate_prorated_price as _calc_prorated
|
||||
|
||||
if traffic_discount_percent > 0:
|
||||
base_price_kopeks = int(base_price_kopeks * (100 - traffic_discount_percent) / 100)
|
||||
|
||||
# Calculate prorated price
|
||||
final_price, _ = calculate_prorated_price(
|
||||
now = datetime.now(UTC)
|
||||
days_left = max(1, (subscription.end_date - now).days)
|
||||
prorated_price, _ = _calc_prorated(
|
||||
base_price_kopeks,
|
||||
subscription.end_date,
|
||||
)
|
||||
discount_result = _apply_addon_discount(user, 'traffic', prorated_price, days_left)
|
||||
final_price = discount_result['discounted']
|
||||
traffic_discount_percent = discount_result['percent']
|
||||
|
||||
# Save cart for auto-purchase after balance top-up
|
||||
cart_data = {
|
||||
@@ -2812,14 +2809,26 @@ async def save_devices_cart(
|
||||
days_left = max(1, (end_date - now).days)
|
||||
total_days = 30
|
||||
|
||||
price_kopeks = int(device_price * request.devices * days_left / total_days)
|
||||
price_kopeks = max(100, price_kopeks) # Minimum 1 ruble
|
||||
base_total_price = int(device_price * request.devices * days_left / total_days)
|
||||
base_total_price = max(100, base_total_price) # Minimum 1 ruble
|
||||
|
||||
# Apply discount from promo group
|
||||
period_hint_days = days_left
|
||||
discount_result = _apply_addon_discount(user, 'devices', base_total_price, period_hint_days)
|
||||
price_kopeks = discount_result['discounted']
|
||||
devices_discount_percent = discount_result['percent']
|
||||
|
||||
# Ensure minimum price after discount (except for 100% discount)
|
||||
if devices_discount_percent < 100 and price_kopeks > 0:
|
||||
price_kopeks = max(100, price_kopeks)
|
||||
|
||||
# Save cart for auto-purchase after balance top-up
|
||||
cart_data = {
|
||||
'cart_mode': 'add_devices',
|
||||
'devices_to_add': request.devices,
|
||||
'price_kopeks': price_kopeks,
|
||||
'base_price_kopeks': base_total_price,
|
||||
'discount_percent': devices_discount_percent,
|
||||
'source': 'cabinet',
|
||||
}
|
||||
await user_cart_service.save_user_cart(user.id, cart_data)
|
||||
@@ -2897,10 +2906,9 @@ async def get_device_price(
|
||||
days_left = max(1, (end_date - now).days)
|
||||
total_days = 30
|
||||
|
||||
# Calculate base price before discount
|
||||
base_price_per_device = int(device_price * days_left / total_days)
|
||||
base_price_per_device = max(100, base_price_per_device)
|
||||
base_total_price = base_price_per_device * devices
|
||||
# Calculate base price before discount (total first, then floor)
|
||||
base_total_price = int(device_price * devices * days_left / total_days)
|
||||
base_total_price = max(100, base_total_price)
|
||||
|
||||
# Apply discount from promo group
|
||||
period_hint_days = days_left
|
||||
@@ -2909,7 +2917,7 @@ async def get_device_price(
|
||||
devices_discount_percent = discount_result['percent']
|
||||
discount_value = discount_result['discount']
|
||||
|
||||
# Calculate per-device price after discount
|
||||
# Ensure minimum price after discount (except for 100% discount)
|
||||
if devices_discount_percent < 100 and total_price_kopeks > 0:
|
||||
total_price_kopeks = max(100, total_price_kopeks)
|
||||
price_per_device_kopeks = total_price_kopeks // devices if devices > 0 else 0
|
||||
@@ -3256,7 +3264,7 @@ async def update_countries(
|
||||
else:
|
||||
discounted_per_month = server_price_per_month
|
||||
|
||||
charged_price, charged_months = calculate_prorated_price(
|
||||
charged_price, charged_days = calculate_prorated_price(
|
||||
discounted_per_month,
|
||||
user.subscription.end_date,
|
||||
)
|
||||
@@ -4345,6 +4353,16 @@ async def switch_tariff(
|
||||
description=description,
|
||||
payment_method=PaymentMethod.BALANCE,
|
||||
)
|
||||
else:
|
||||
# Free switch (downgrade) — record in history
|
||||
description = f"Переход на тариф '{new_tariff.name}'"
|
||||
await create_transaction(
|
||||
db=db,
|
||||
user_id=user.id,
|
||||
type=TransactionType.SUBSCRIPTION_PAYMENT,
|
||||
amount_kopeks=0,
|
||||
description=description,
|
||||
)
|
||||
|
||||
# Update subscription
|
||||
old_tariff_name = current_tariff.name if current_tariff else 'Unknown'
|
||||
@@ -4668,7 +4686,7 @@ async def switch_traffic_package(
|
||||
price_diff = int(price_diff * (100 - traffic_discount_percent) / 100)
|
||||
|
||||
# Prorated calculation
|
||||
final_price, months_charged = calculate_prorated_price(price_diff, user.subscription.end_date)
|
||||
final_price, days_charged = calculate_prorated_price(price_diff, user.subscription.end_date)
|
||||
|
||||
if user.balance_kopeks < final_price:
|
||||
raise HTTPException(
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
from datetime import datetime
|
||||
from typing import Any
|
||||
|
||||
from pydantic import BaseModel, Field
|
||||
from pydantic import BaseModel, ConfigDict, Field
|
||||
|
||||
|
||||
class BalanceResponse(BaseModel):
|
||||
@@ -26,8 +26,7 @@ class TransactionResponse(BaseModel):
|
||||
created_at: datetime
|
||||
completed_at: datetime | None = None
|
||||
|
||||
class Config:
|
||||
from_attributes = True
|
||||
model_config = ConfigDict(from_attributes=True)
|
||||
|
||||
|
||||
class TransactionListResponse(BaseModel):
|
||||
@@ -114,8 +113,7 @@ class PendingPaymentResponse(BaseModel):
|
||||
user_telegram_id: int | None = None
|
||||
user_username: str | None = None
|
||||
|
||||
class Config:
|
||||
from_attributes = True
|
||||
model_config = ConfigDict(from_attributes=True)
|
||||
|
||||
|
||||
class PendingPaymentListResponse(BaseModel):
|
||||
@@ -137,3 +135,23 @@ class ManualCheckResponse(BaseModel):
|
||||
status_changed: bool = False
|
||||
old_status: str | None = None
|
||||
new_status: str | None = None
|
||||
|
||||
|
||||
class SavedCardResponse(BaseModel):
|
||||
"""Saved payment method (card) for recurrent payments."""
|
||||
|
||||
id: int
|
||||
method_type: str
|
||||
card_last4: str | None = None
|
||||
card_type: str | None = None
|
||||
title: str | None = None
|
||||
created_at: datetime
|
||||
|
||||
model_config = ConfigDict(from_attributes=True)
|
||||
|
||||
|
||||
class SavedCardsListResponse(BaseModel):
|
||||
"""List of saved payment methods."""
|
||||
|
||||
cards: list[SavedCardResponse]
|
||||
recurrent_enabled: bool = False
|
||||
|
||||
@@ -45,13 +45,16 @@ class GiftConfigResponse(BaseModel):
|
||||
payment_methods: list[GiftConfigPaymentMethod] = []
|
||||
balance_kopeks: int = 0
|
||||
currency_symbol: str = '\u20bd'
|
||||
promo_group_name: str | None = None
|
||||
active_discount_percent: int | None = None
|
||||
active_discount_expires_at: datetime | None = None
|
||||
|
||||
|
||||
class GiftPurchaseRequest(BaseModel):
|
||||
tariff_id: int = Field(gt=0)
|
||||
period_days: int = Field(gt=0, le=3650)
|
||||
recipient_type: str = Field(pattern=r'^(email|telegram)$')
|
||||
recipient_value: str = Field(min_length=1, max_length=255)
|
||||
recipient_type: str | None = Field(default=None, pattern=r'^(email|telegram)$')
|
||||
recipient_value: str | None = Field(default=None, max_length=255)
|
||||
gift_message: str | None = Field(default=None, max_length=1000)
|
||||
payment_mode: str = Field(pattern=r'^(balance|gateway)$')
|
||||
payment_method: str | None = Field(default=None, max_length=50)
|
||||
@@ -73,6 +76,8 @@ class GiftPurchaseResponse(BaseModel):
|
||||
class GiftPurchaseStatusResponse(BaseModel):
|
||||
status: str
|
||||
is_gift: bool = True
|
||||
is_code_only: bool = False
|
||||
purchase_token: str | None = None
|
||||
recipient_contact_value: str | None = None
|
||||
gift_message: str | None = None
|
||||
tariff_name: str | None = None
|
||||
@@ -87,3 +92,40 @@ class PendingGiftResponse(BaseModel):
|
||||
gift_message: str | None = None
|
||||
sender_display: str | None = None
|
||||
created_at: datetime | None = None
|
||||
|
||||
|
||||
class SentGiftResponse(BaseModel):
|
||||
"""A gift the current user has sent."""
|
||||
|
||||
token: str
|
||||
tariff_name: str | None = None
|
||||
period_days: int
|
||||
device_limit: int = 1
|
||||
status: str
|
||||
gift_recipient_value: str | None = None
|
||||
gift_message: str | None = None
|
||||
activated_by_username: str | None = None
|
||||
created_at: datetime | None = None
|
||||
|
||||
|
||||
class ReceivedGiftResponse(BaseModel):
|
||||
"""A gift the current user has received."""
|
||||
|
||||
token: str
|
||||
tariff_name: str | None = None
|
||||
period_days: int
|
||||
device_limit: int = 1
|
||||
status: str
|
||||
sender_display: str | None = None
|
||||
gift_message: str | None = None
|
||||
created_at: datetime | None = None
|
||||
|
||||
|
||||
class ActivateGiftRequest(BaseModel):
|
||||
code: str = Field(min_length=1, max_length=100)
|
||||
|
||||
|
||||
class ActivateGiftResponse(BaseModel):
|
||||
status: str
|
||||
tariff_name: str | None = None
|
||||
period_days: int | None = None
|
||||
|
||||
@@ -80,4 +80,5 @@ class ReferralTermsResponse(BaseModel):
|
||||
first_topup_bonus_rubles: float
|
||||
inviter_bonus_kopeks: int
|
||||
inviter_bonus_rubles: float
|
||||
max_commission_payments: int = 0
|
||||
partner_section_visible: bool = True
|
||||
|
||||
@@ -54,6 +54,7 @@ class TariffListItem(BaseModel):
|
||||
is_daily: bool = False
|
||||
daily_price_kopeks: int = 0
|
||||
allow_traffic_topup: bool = True
|
||||
show_in_gift: bool = True
|
||||
traffic_limit_gb: int
|
||||
device_limit: int
|
||||
tier_level: int
|
||||
@@ -114,6 +115,8 @@ class TariffDetailResponse(BaseModel):
|
||||
traffic_reset_mode: str | None = None # DAY, WEEK, MONTH, NO_RESET, None = глобальная настройка
|
||||
# Внешний сквад RemnaWave
|
||||
external_squad_uuid: str | None = None
|
||||
# Показывать в подарках
|
||||
show_in_gift: bool = True
|
||||
created_at: datetime
|
||||
updated_at: datetime | None = None
|
||||
|
||||
@@ -170,6 +173,8 @@ class TariffCreateRequest(BaseModel):
|
||||
traffic_reset_mode: str | None = None # DAY, WEEK, MONTH, NO_RESET, None = глобальная настройка
|
||||
# Внешний сквад RemnaWave
|
||||
external_squad_uuid: str | None = Field(None, pattern=UUID_PATTERN)
|
||||
# Показывать в подарках
|
||||
show_in_gift: bool = True
|
||||
|
||||
|
||||
class TariffUpdateRequest(BaseModel):
|
||||
@@ -209,6 +214,8 @@ class TariffUpdateRequest(BaseModel):
|
||||
traffic_reset_mode: str | None = None # DAY, WEEK, MONTH, NO_RESET, None = глобальная настройка
|
||||
# Внешний сквад RemnaWave
|
||||
external_squad_uuid: str | None = Field(None, pattern=UUID_PATTERN)
|
||||
# Показывать в подарках
|
||||
show_in_gift: bool | None = None
|
||||
|
||||
|
||||
class TariffSortOrderRequest(BaseModel):
|
||||
@@ -243,3 +250,15 @@ class TariffStatsResponse(BaseModel):
|
||||
trial_subscriptions: int
|
||||
revenue_kopeks: int
|
||||
revenue_rubles: float
|
||||
|
||||
|
||||
class SyncSquadsResponse(BaseModel):
|
||||
"""Response after syncing squads for tariff subscriptions."""
|
||||
|
||||
tariff_id: int
|
||||
tariff_name: str
|
||||
total_subscriptions: int
|
||||
updated_count: int
|
||||
failed_count: int
|
||||
skipped_count: int
|
||||
errors: list[str] = Field(default_factory=list)
|
||||
|
||||
@@ -1,13 +1,13 @@
|
||||
"""Schemas for Admin Users management in cabinet."""
|
||||
|
||||
from datetime import datetime
|
||||
from enum import Enum
|
||||
from enum import StrEnum
|
||||
from typing import Any
|
||||
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
|
||||
class UserStatusEnum(str, Enum):
|
||||
class UserStatusEnum(StrEnum):
|
||||
"""User status enum."""
|
||||
|
||||
ACTIVE = 'active'
|
||||
@@ -15,7 +15,7 @@ class UserStatusEnum(str, Enum):
|
||||
DELETED = 'deleted'
|
||||
|
||||
|
||||
class SubscriptionStatusEnum(str, Enum):
|
||||
class SubscriptionStatusEnum(StrEnum):
|
||||
"""Subscription status enum."""
|
||||
|
||||
TRIAL = 'trial'
|
||||
@@ -25,7 +25,7 @@ class SubscriptionStatusEnum(str, Enum):
|
||||
PENDING = 'pending'
|
||||
|
||||
|
||||
class SortByEnum(str, Enum):
|
||||
class SortByEnum(StrEnum):
|
||||
"""Sort options for users list."""
|
||||
|
||||
CREATED_AT = 'created_at'
|
||||
@@ -281,7 +281,7 @@ class UpdateSubscriptionRequest(BaseModel):
|
||||
"""Request to update user subscription."""
|
||||
|
||||
action: str = Field(
|
||||
..., description='Action: extend, set_end_date, change_tariff, set_traffic, toggle_autopay, cancel'
|
||||
..., description='Action: extend, shorten, set_end_date, change_tariff, set_traffic, toggle_autopay, cancel'
|
||||
)
|
||||
|
||||
# For extend action
|
||||
@@ -696,3 +696,40 @@ class DisableUserResponse(BaseModel):
|
||||
panel_deactivated: bool = False
|
||||
user_blocked: bool = False
|
||||
panel_error: str | None = None
|
||||
|
||||
|
||||
# === Gifts ===
|
||||
|
||||
|
||||
class AdminUserGiftItem(BaseModel):
|
||||
"""Gift item for admin user detail view."""
|
||||
|
||||
id: int
|
||||
token: str
|
||||
status: str
|
||||
tariff_name: str | None = None
|
||||
period_days: int
|
||||
device_limit: int = 1
|
||||
amount_kopeks: int
|
||||
payment_method: str | None = None
|
||||
gift_recipient_type: str | None = None
|
||||
gift_recipient_value: str | None = None
|
||||
gift_message: str | None = None
|
||||
buyer_user_id: int | None = None
|
||||
buyer_username: str | None = None
|
||||
buyer_full_name: str | None = None
|
||||
receiver_user_id: int | None = None
|
||||
receiver_username: str | None = None
|
||||
receiver_full_name: str | None = None
|
||||
created_at: datetime | None = None
|
||||
paid_at: datetime | None = None
|
||||
delivered_at: datetime | None = None
|
||||
|
||||
|
||||
class AdminUserGiftsResponse(BaseModel):
|
||||
"""Response with sent and received gifts for admin user detail."""
|
||||
|
||||
sent: list[AdminUserGiftItem] = []
|
||||
received: list[AdminUserGiftItem] = []
|
||||
sent_total: int = 0
|
||||
received_total: int = 0
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
"""Схемы для колеса удачи (Fortune Wheel)."""
|
||||
|
||||
from datetime import datetime
|
||||
from enum import Enum
|
||||
from enum import StrEnum
|
||||
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
@@ -9,14 +9,14 @@ from pydantic import BaseModel, Field
|
||||
# ==================== ENUMS ====================
|
||||
|
||||
|
||||
class WheelPaymentType(str, Enum):
|
||||
class WheelPaymentType(StrEnum):
|
||||
"""Способы оплаты спина."""
|
||||
|
||||
TELEGRAM_STARS = 'telegram_stars'
|
||||
SUBSCRIPTION_DAYS = 'subscription_days'
|
||||
|
||||
|
||||
class WheelPrizeType(str, Enum):
|
||||
class WheelPrizeType(StrEnum):
|
||||
"""Типы призов."""
|
||||
|
||||
SUBSCRIPTION_DAYS = 'subscription_days'
|
||||
|
||||
+67
-23
@@ -1,7 +1,6 @@
|
||||
import hashlib
|
||||
import hmac
|
||||
import html
|
||||
import math
|
||||
import os
|
||||
import re
|
||||
from collections import defaultdict
|
||||
@@ -220,6 +219,7 @@ class Settings(BaseSettings):
|
||||
REFERRAL_FIRST_TOPUP_BONUS_KOPEKS: int = 10000
|
||||
REFERRAL_INVITER_BONUS_KOPEKS: int = 10000
|
||||
REFERRAL_COMMISSION_PERCENT: int = 25
|
||||
REFERRAL_MAX_COMMISSION_PAYMENTS: int = 0 # Макс. кол-во платежей реферала с комиссией (0 = без лимита)
|
||||
|
||||
REFERRAL_PROGRAM_ENABLED: bool = True
|
||||
REFERRAL_NOTIFICATIONS_ENABLED: bool = True
|
||||
@@ -354,6 +354,8 @@ class Settings(BaseSettings):
|
||||
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
|
||||
@@ -534,6 +536,18 @@ class Settings(BaseSettings):
|
||||
# Способ оплаты: 44 = СБП (QR код), 36 = Карты РФ, 43 = SberPay
|
||||
KASSA_AI_PAYMENT_SYSTEM_ID: int = 44
|
||||
|
||||
# RioPay (api.riopay.online) v2.0.1
|
||||
RIOPAY_ENABLED: bool = False
|
||||
RIOPAY_API_TOKEN: str | None = None # x-api-token header
|
||||
RIOPAY_WEBHOOK_SECRET: str | None = None # HMAC-SHA512 ключ для вебхуков (по умолчанию = API_TOKEN)
|
||||
RIOPAY_DISPLAY_NAME: str = 'RioPay'
|
||||
RIOPAY_CURRENCY: str = 'RUB'
|
||||
RIOPAY_MIN_AMOUNT_KOPEKS: int = 10000 # 100₽
|
||||
RIOPAY_MAX_AMOUNT_KOPEKS: int = 100000000 # 1 000 000₽
|
||||
RIOPAY_WEBHOOK_PATH: str = '/riopay-webhook'
|
||||
RIOPAY_SUCCESS_URL: str | None = None
|
||||
RIOPAY_FAIL_URL: str | None = None
|
||||
|
||||
MAIN_MENU_MODE: str = 'default' # 'default' | 'cabinet'
|
||||
# Стиль кнопок Cabinet: primary (синий), success (зелёный), danger (красный), '' (по умолчанию для каждой секции)
|
||||
CABINET_BUTTON_STYLE: str = ''
|
||||
@@ -937,12 +951,12 @@ class Settings(BaseSettings):
|
||||
def get_test_email(self) -> str | None:
|
||||
"""Get test email for development/testing."""
|
||||
email = (self.TEST_EMAIL or '').strip().lower()
|
||||
return email if email else None
|
||||
return email or None
|
||||
|
||||
def get_test_email_password(self) -> str | None:
|
||||
"""Get test email password."""
|
||||
password = (self.TEST_EMAIL_PASSWORD or '').strip()
|
||||
return password if password else None
|
||||
return password or None
|
||||
|
||||
def is_test_email(self, email: str) -> bool:
|
||||
"""Check if email is the configured test email."""
|
||||
@@ -1401,6 +1415,26 @@ class Settings(BaseSettings):
|
||||
return value
|
||||
return None
|
||||
|
||||
_CABINET_URL_DEFAULT = 'https://example.com/cabinet'
|
||||
|
||||
def get_referral_link(self, referral_code: str, bot_username: str | None = None) -> str:
|
||||
"""Build a referral link pointing to the web cabinet.
|
||||
|
||||
Falls back to a Telegram bot deep link when CABINET_URL is not configured.
|
||||
"""
|
||||
from urllib.parse import quote
|
||||
|
||||
if not referral_code:
|
||||
raise ValueError('referral_code must not be empty or None')
|
||||
|
||||
safe_code = quote(referral_code, safe='')
|
||||
cabinet_url = (self.CABINET_URL or '').strip().rstrip('/')
|
||||
if cabinet_url and cabinet_url != self._CABINET_URL_DEFAULT:
|
||||
sep = '&' if '?' in cabinet_url else '?'
|
||||
return f'{cabinet_url}{sep}ref={safe_code}'
|
||||
username = bot_username or self.get_bot_username() or 'bot'
|
||||
return f'https://t.me/{username}?start={safe_code}'
|
||||
|
||||
def is_deep_links_enabled(self) -> bool:
|
||||
return self.ENABLE_DEEP_LINKS
|
||||
|
||||
@@ -1507,7 +1541,7 @@ class Settings(BaseSettings):
|
||||
except (ValueError, IndexError):
|
||||
continue
|
||||
|
||||
return packages if packages else self.get_traffic_packages()
|
||||
return packages or self.get_traffic_packages()
|
||||
|
||||
def get_traffic_topup_price(self, gb: int | None) -> int:
|
||||
"""Возвращает цену докупки для указанного количества ГБ."""
|
||||
@@ -1597,7 +1631,7 @@ class Settings(BaseSettings):
|
||||
|
||||
def get_yookassa_display_name(self) -> str:
|
||||
name = (self.YOOKASSA_DISPLAY_NAME or '').strip()
|
||||
return name if name else 'YooKassa'
|
||||
return name or 'YooKassa'
|
||||
|
||||
def is_nalogo_enabled(self) -> bool:
|
||||
return self.NALOGO_ENABLED and self.NALOGO_INN is not None and self.NALOGO_PASSWORD is not None
|
||||
@@ -1617,14 +1651,14 @@ class Settings(BaseSettings):
|
||||
|
||||
def get_cryptobot_display_name(self) -> str:
|
||||
name = (self.CRYPTOBOT_DISPLAY_NAME or '').strip()
|
||||
return name if name else 'CryptoBot'
|
||||
return name or 'CryptoBot'
|
||||
|
||||
def is_heleket_enabled(self) -> bool:
|
||||
return self.HELEKET_ENABLED and self.HELEKET_MERCHANT_ID is not None and self.HELEKET_API_KEY is not None
|
||||
|
||||
def get_heleket_display_name(self) -> str:
|
||||
name = (self.HELEKET_DISPLAY_NAME or '').strip()
|
||||
return name if name else 'Heleket Crypto'
|
||||
return name or 'Heleket Crypto'
|
||||
|
||||
def is_mulenpay_enabled(self) -> bool:
|
||||
return (
|
||||
@@ -1662,7 +1696,7 @@ class Settings(BaseSettings):
|
||||
|
||||
def get_pal24_display_name(self) -> str:
|
||||
name = (self.PAL24_DISPLAY_NAME or '').strip()
|
||||
return name if name else 'PAL24'
|
||||
return name or 'PAL24'
|
||||
|
||||
def is_platega_enabled(self) -> bool:
|
||||
return self.PLATEGA_ENABLED and self.PLATEGA_MERCHANT_ID is not None and self.PLATEGA_SECRET is not None
|
||||
@@ -1742,7 +1776,7 @@ class Settings(BaseSettings):
|
||||
|
||||
def get_wata_display_name(self) -> str:
|
||||
name = (self.WATA_DISPLAY_NAME or '').strip()
|
||||
return name if name else 'Wata'
|
||||
return name or 'Wata'
|
||||
|
||||
def is_cloudpayments_enabled(self) -> bool:
|
||||
return (
|
||||
@@ -1753,7 +1787,7 @@ class Settings(BaseSettings):
|
||||
|
||||
def get_cloudpayments_display_name(self) -> str:
|
||||
name = (self.CLOUDPAYMENTS_DISPLAY_NAME or '').strip()
|
||||
return name if name else 'CloudPayments'
|
||||
return name or 'CloudPayments'
|
||||
|
||||
def is_freekassa_enabled(self) -> bool:
|
||||
return (
|
||||
@@ -1766,7 +1800,7 @@ class Settings(BaseSettings):
|
||||
|
||||
def get_freekassa_display_name(self) -> str:
|
||||
name = (self.FREEKASSA_DISPLAY_NAME or '').strip()
|
||||
return name if name else 'Freekassa'
|
||||
return name or 'Freekassa'
|
||||
|
||||
def get_freekassa_display_name_html(self) -> str:
|
||||
return html.escape(self.get_freekassa_display_name())
|
||||
@@ -1776,7 +1810,7 @@ class Settings(BaseSettings):
|
||||
|
||||
def get_freekassa_sbp_display_name(self) -> str:
|
||||
name = (self.FREEKASSA_SBP_DISPLAY_NAME or '').strip()
|
||||
return name if name else 'СБП (QR код)'
|
||||
return name or 'СБП (QR код)'
|
||||
|
||||
def get_freekassa_sbp_display_name_html(self) -> str:
|
||||
return html.escape(self.get_freekassa_sbp_display_name())
|
||||
@@ -1786,7 +1820,7 @@ class Settings(BaseSettings):
|
||||
|
||||
def get_freekassa_card_display_name(self) -> str:
|
||||
name = (self.FREEKASSA_CARD_DISPLAY_NAME or '').strip()
|
||||
return name if name else 'Карта РФ'
|
||||
return name or 'Карта РФ'
|
||||
|
||||
def get_freekassa_card_display_name_html(self) -> str:
|
||||
return html.escape(self.get_freekassa_card_display_name())
|
||||
@@ -1801,11 +1835,21 @@ class Settings(BaseSettings):
|
||||
|
||||
def get_kassa_ai_display_name(self) -> str:
|
||||
name = (self.KASSA_AI_DISPLAY_NAME or '').strip()
|
||||
return name if name else 'KassaAI'
|
||||
return name or 'KassaAI'
|
||||
|
||||
def get_kassa_ai_display_name_html(self) -> str:
|
||||
return html.escape(self.get_kassa_ai_display_name())
|
||||
|
||||
def is_riopay_enabled(self) -> bool:
|
||||
return self.RIOPAY_ENABLED and self.RIOPAY_API_TOKEN is not None
|
||||
|
||||
def get_riopay_display_name(self) -> str:
|
||||
name = (self.RIOPAY_DISPLAY_NAME or '').strip()
|
||||
return name or 'RioPay'
|
||||
|
||||
def get_riopay_display_name_html(self) -> str:
|
||||
return html.escape(self.get_riopay_display_name())
|
||||
|
||||
def is_payment_verification_auto_check_enabled(self) -> bool:
|
||||
return self.PAYMENT_VERIFICATION_AUTO_CHECK_ENABLED
|
||||
|
||||
@@ -1901,7 +1945,7 @@ class Settings(BaseSettings):
|
||||
'windows': ((self.HAPP_DOWNLOAD_LINK_WINDOWS or '').strip() or (self.HAPP_DOWNLOAD_LINK_PC or '').strip()),
|
||||
}
|
||||
link = links.get(platform_key)
|
||||
return link if link else None
|
||||
return link or None
|
||||
|
||||
def is_maintenance_mode(self) -> bool:
|
||||
return self.MAINTENANCE_MODE
|
||||
@@ -1989,7 +2033,7 @@ class Settings(BaseSettings):
|
||||
# т.к. в режиме classic цена складывается из серверов/трафика/устройств)
|
||||
periods = sorted(allowed_periods)
|
||||
|
||||
return periods if periods else [30, 90, 180]
|
||||
return periods or [30, 90, 180]
|
||||
|
||||
def get_available_renewal_periods(self) -> list[int]:
|
||||
"""
|
||||
@@ -2014,7 +2058,7 @@ class Settings(BaseSettings):
|
||||
# Возвращаем только разрешённые периоды (без фильтрации по цене)
|
||||
periods = sorted(allowed_periods)
|
||||
|
||||
return periods if periods else [30, 90, 180]
|
||||
return periods or [30, 90, 180]
|
||||
|
||||
def get_configured_subscription_periods(self) -> list[int]:
|
||||
"""
|
||||
@@ -2079,7 +2123,7 @@ class Settings(BaseSettings):
|
||||
|
||||
def get_telegram_stars_display_name(self) -> str:
|
||||
name = (self.TELEGRAM_STARS_DISPLAY_NAME or '').strip()
|
||||
return name if name else 'Telegram Stars'
|
||||
return name or 'Telegram Stars'
|
||||
|
||||
def stars_to_rubles(self, stars: int) -> float:
|
||||
return stars * self.get_stars_rate()
|
||||
@@ -2088,7 +2132,7 @@ class Settings(BaseSettings):
|
||||
rate = self.get_stars_rate()
|
||||
if rate <= 0:
|
||||
raise ValueError('Stars rate must be positive')
|
||||
return max(1, math.ceil(rubles / rate))
|
||||
return max(1, round(rubles / rate))
|
||||
|
||||
def get_admin_notifications_chat_id(self) -> int | None:
|
||||
if not self.ADMIN_NOTIFICATIONS_CHAT_ID:
|
||||
@@ -2116,7 +2160,7 @@ class Settings(BaseSettings):
|
||||
|
||||
def get_backup_archive_password(self) -> str | None:
|
||||
password = (self.BACKUP_ARCHIVE_PASSWORD or '').strip()
|
||||
return password if password else None
|
||||
return password or None
|
||||
|
||||
# === Log Rotation Methods ===
|
||||
|
||||
@@ -2197,7 +2241,7 @@ class Settings(BaseSettings):
|
||||
except ValueError:
|
||||
continue
|
||||
|
||||
return packages if packages else self._get_fallback_traffic_packages()
|
||||
return packages or self._get_fallback_traffic_packages()
|
||||
|
||||
except Exception as e:
|
||||
logger.warning('ERROR PARSING CONFIG', error=e)
|
||||
@@ -2330,13 +2374,13 @@ class Settings(BaseSettings):
|
||||
|
||||
if contact.startswith(('t.me/', 'telegram.me/', 'telegram.dog/')):
|
||||
url = self.get_support_contact_url()
|
||||
return url if url else contact
|
||||
return url or contact
|
||||
|
||||
contact_without_prefix = contact.lstrip('@')
|
||||
|
||||
if '.' in contact_without_prefix:
|
||||
url = self.get_support_contact_url()
|
||||
return url if url else contact
|
||||
return url or contact
|
||||
|
||||
if re.fullmatch(r'[A-Za-z0-9_]{3,}', contact_without_prefix):
|
||||
return f'@{contact_without_prefix}'
|
||||
|
||||
@@ -5,6 +5,7 @@ from sqlalchemy import and_, delete, func, select, update
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from sqlalchemy.orm import selectinload
|
||||
|
||||
from app.database.crud.transaction import REAL_PAYMENT_METHODS
|
||||
from app.database.models import (
|
||||
AdvertisingCampaign,
|
||||
AdvertisingCampaignRegistration,
|
||||
@@ -268,11 +269,13 @@ async def get_campaign_statistics(
|
||||
)
|
||||
subscription_bonuses_issued = subscription_count_result.scalar() or 0
|
||||
|
||||
# Only count real deposits (exclude promo bonuses, wheel prizes, admin top-ups)
|
||||
deposits_result = await db.execute(
|
||||
select(func.coalesce(func.sum(Transaction.amount_kopeks), 0)).where(
|
||||
Transaction.user_id.in_(select(registrations_subquery.c.user_id)),
|
||||
Transaction.type == TransactionType.DEPOSIT.value,
|
||||
Transaction.is_completed.is_(True),
|
||||
Transaction.payment_method.in_(REAL_PAYMENT_METHODS),
|
||||
)
|
||||
)
|
||||
deposits_total = deposits_result.scalar() or 0
|
||||
|
||||
@@ -48,9 +48,10 @@ async def record_notification(
|
||||
await db.commit()
|
||||
|
||||
|
||||
async def clear_notifications(db: AsyncSession, subscription_id: int) -> None:
|
||||
async def clear_notifications(db: AsyncSession, subscription_id: int, *, commit: bool = True) -> None:
|
||||
await db.execute(delete(SentNotification).where(SentNotification.subscription_id == subscription_id))
|
||||
await db.commit()
|
||||
if commit:
|
||||
await db.commit()
|
||||
|
||||
|
||||
async def clear_notification_by_type(
|
||||
|
||||
@@ -50,6 +50,20 @@ async def create_referral_earning(
|
||||
return earning
|
||||
|
||||
|
||||
async def get_commission_payment_count(db: AsyncSession, referrer_id: int, referral_id: int) -> int:
|
||||
"""Подсчитать количество комиссионных начислений реферера за платежи конкретного реферала."""
|
||||
result = await db.execute(
|
||||
select(func.count(ReferralEarning.id)).where(
|
||||
and_(
|
||||
ReferralEarning.user_id == referrer_id,
|
||||
ReferralEarning.referral_id == referral_id,
|
||||
ReferralEarning.reason == 'referral_commission_topup',
|
||||
)
|
||||
)
|
||||
)
|
||||
return result.scalar() or 0
|
||||
|
||||
|
||||
async def get_referral_earnings_by_user(
|
||||
db: AsyncSession, user_id: int, limit: int = 50, offset: int = 0
|
||||
) -> list[ReferralEarning]:
|
||||
|
||||
@@ -0,0 +1,132 @@
|
||||
"""CRUD операции для платежей RioPay."""
|
||||
|
||||
from datetime import UTC, datetime
|
||||
|
||||
import structlog
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.database.models import RioPayPayment
|
||||
|
||||
|
||||
logger = structlog.get_logger(__name__)
|
||||
|
||||
|
||||
async def create_riopay_payment(
|
||||
db: AsyncSession,
|
||||
*,
|
||||
user_id: int,
|
||||
order_id: str,
|
||||
amount_kopeks: int,
|
||||
currency: str = 'RUB',
|
||||
description: str | None = None,
|
||||
payment_url: str | None = None,
|
||||
payment_method: str | None = None,
|
||||
riopay_order_id: str | None = None,
|
||||
expires_at: datetime | None = None,
|
||||
metadata_json: dict | None = None,
|
||||
) -> RioPayPayment:
|
||||
"""Создает запись о платеже RioPay."""
|
||||
payment = RioPayPayment(
|
||||
user_id=user_id,
|
||||
order_id=order_id,
|
||||
amount_kopeks=amount_kopeks,
|
||||
currency=currency,
|
||||
description=description,
|
||||
payment_url=payment_url,
|
||||
payment_method=payment_method,
|
||||
riopay_order_id=riopay_order_id,
|
||||
expires_at=expires_at,
|
||||
metadata_json=metadata_json,
|
||||
status='pending',
|
||||
is_paid=False,
|
||||
)
|
||||
db.add(payment)
|
||||
await db.commit()
|
||||
await db.refresh(payment)
|
||||
logger.info('Создан платеж RioPay', order_id=order_id, user_id=user_id)
|
||||
return payment
|
||||
|
||||
|
||||
async def get_riopay_payment_by_order_id(db: AsyncSession, order_id: str) -> RioPayPayment | None:
|
||||
"""Получает платеж по order_id (internal)."""
|
||||
result = await db.execute(select(RioPayPayment).where(RioPayPayment.order_id == order_id))
|
||||
return result.scalar_one_or_none()
|
||||
|
||||
|
||||
async def get_riopay_payment_by_riopay_order_id(db: AsyncSession, riopay_order_id: str) -> RioPayPayment | None:
|
||||
"""Получает платеж по ID от RioPay (UUID)."""
|
||||
result = await db.execute(select(RioPayPayment).where(RioPayPayment.riopay_order_id == riopay_order_id))
|
||||
return result.scalar_one_or_none()
|
||||
|
||||
|
||||
async def get_riopay_payment_by_id(db: AsyncSession, payment_id: int) -> RioPayPayment | None:
|
||||
"""Получает платеж по ID."""
|
||||
result = await db.execute(select(RioPayPayment).where(RioPayPayment.id == payment_id))
|
||||
return result.scalar_one_or_none()
|
||||
|
||||
|
||||
async def update_riopay_payment_status(
|
||||
db: AsyncSession,
|
||||
payment: RioPayPayment,
|
||||
*,
|
||||
status: str,
|
||||
is_paid: bool | None = None,
|
||||
riopay_order_id: str | None = None,
|
||||
payment_method: str | None = None,
|
||||
callback_payload: dict | None = None,
|
||||
transaction_id: int | None = None,
|
||||
) -> RioPayPayment:
|
||||
"""Обновляет статус платежа."""
|
||||
payment.status = status
|
||||
payment.updated_at = datetime.now(UTC)
|
||||
|
||||
if is_paid is not None:
|
||||
payment.is_paid = is_paid
|
||||
if is_paid:
|
||||
payment.paid_at = datetime.now(UTC)
|
||||
if riopay_order_id:
|
||||
payment.riopay_order_id = riopay_order_id
|
||||
if payment_method is not None:
|
||||
payment.payment_method = payment_method
|
||||
if callback_payload:
|
||||
payment.callback_payload = callback_payload
|
||||
if transaction_id:
|
||||
payment.transaction_id = transaction_id
|
||||
|
||||
await db.commit()
|
||||
await db.refresh(payment)
|
||||
logger.info(
|
||||
'Обновлен статус платежа RioPay',
|
||||
order_id=payment.order_id,
|
||||
status=status,
|
||||
is_paid=payment.is_paid,
|
||||
)
|
||||
return payment
|
||||
|
||||
|
||||
async def get_pending_riopay_payments(db: AsyncSession, user_id: int) -> list[RioPayPayment]:
|
||||
"""Получает незавершенные платежи пользователя."""
|
||||
result = await db.execute(
|
||||
select(RioPayPayment).where(
|
||||
RioPayPayment.user_id == user_id,
|
||||
RioPayPayment.status == 'pending',
|
||||
RioPayPayment.is_paid == False,
|
||||
)
|
||||
)
|
||||
return list(result.scalars().all())
|
||||
|
||||
|
||||
async def get_expired_pending_riopay_payments(
|
||||
db: AsyncSession,
|
||||
) -> list[RioPayPayment]:
|
||||
"""Получает просроченные платежи в статусе pending."""
|
||||
now = datetime.now(UTC)
|
||||
result = await db.execute(
|
||||
select(RioPayPayment).where(
|
||||
RioPayPayment.status == 'pending',
|
||||
RioPayPayment.is_paid == False,
|
||||
RioPayPayment.expires_at < now,
|
||||
)
|
||||
)
|
||||
return list(result.scalars().all())
|
||||
@@ -0,0 +1,193 @@
|
||||
from datetime import UTC, datetime
|
||||
|
||||
import structlog
|
||||
from sqlalchemy import select, update
|
||||
from sqlalchemy.exc import IntegrityError
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.database.models import SavedPaymentMethod
|
||||
|
||||
|
||||
logger = structlog.get_logger(__name__)
|
||||
|
||||
|
||||
async def create_saved_payment_method(
|
||||
db: AsyncSession,
|
||||
user_id: int,
|
||||
yookassa_payment_method_id: str,
|
||||
method_type: str = 'bank_card',
|
||||
card_first6: str | None = None,
|
||||
card_last4: str | None = None,
|
||||
card_type: str | None = None,
|
||||
card_expiry_month: str | None = None,
|
||||
card_expiry_year: str | None = None,
|
||||
title: str | None = None,
|
||||
) -> SavedPaymentMethod | None:
|
||||
"""Создаёт или реактивирует сохранённый метод оплаты."""
|
||||
|
||||
# Проверяем, есть ли уже такой метод (включая деактивированные)
|
||||
result = await db.execute(
|
||||
update(SavedPaymentMethod)
|
||||
.where(
|
||||
SavedPaymentMethod.yookassa_payment_method_id == yookassa_payment_method_id,
|
||||
SavedPaymentMethod.user_id == user_id,
|
||||
)
|
||||
.values(
|
||||
is_active=True,
|
||||
method_type=method_type,
|
||||
card_first6=card_first6,
|
||||
card_last4=card_last4,
|
||||
card_type=card_type,
|
||||
card_expiry_month=card_expiry_month,
|
||||
card_expiry_year=card_expiry_year,
|
||||
title=title,
|
||||
updated_at=datetime.now(UTC),
|
||||
)
|
||||
.returning(SavedPaymentMethod)
|
||||
)
|
||||
reactivated = result.scalar_one_or_none()
|
||||
if reactivated:
|
||||
await db.commit()
|
||||
logger.info(
|
||||
'Реактивирован сохранённый метод оплаты',
|
||||
saved_method_id=reactivated.id,
|
||||
user_id=user_id,
|
||||
method_type=method_type,
|
||||
card_last4=card_last4,
|
||||
)
|
||||
return reactivated
|
||||
|
||||
method = SavedPaymentMethod(
|
||||
user_id=user_id,
|
||||
yookassa_payment_method_id=yookassa_payment_method_id,
|
||||
method_type=method_type,
|
||||
card_first6=card_first6,
|
||||
card_last4=card_last4,
|
||||
card_type=card_type,
|
||||
card_expiry_month=card_expiry_month,
|
||||
card_expiry_year=card_expiry_year,
|
||||
title=title,
|
||||
)
|
||||
|
||||
db.add(method)
|
||||
try:
|
||||
await db.commit()
|
||||
except IntegrityError as e:
|
||||
await db.rollback()
|
||||
logger.error(
|
||||
'Ошибка создания сохранённого метода оплаты',
|
||||
yookassa_payment_method_id=yookassa_payment_method_id,
|
||||
user_id=user_id,
|
||||
e=e,
|
||||
)
|
||||
return None
|
||||
await db.refresh(method)
|
||||
|
||||
logger.info(
|
||||
'Создан сохранённый метод оплаты',
|
||||
saved_method_id=method.id,
|
||||
user_id=user_id,
|
||||
method_type=method_type,
|
||||
card_last4=card_last4,
|
||||
)
|
||||
return method
|
||||
|
||||
|
||||
async def get_active_payment_methods_by_user(
|
||||
db: AsyncSession,
|
||||
user_id: int,
|
||||
) -> list[SavedPaymentMethod]:
|
||||
"""Получить все активные сохранённые методы оплаты пользователя."""
|
||||
result = await db.execute(
|
||||
select(SavedPaymentMethod)
|
||||
.where(
|
||||
SavedPaymentMethod.user_id == user_id,
|
||||
SavedPaymentMethod.is_active == True,
|
||||
)
|
||||
.order_by(SavedPaymentMethod.created_at.desc())
|
||||
)
|
||||
return list(result.scalars().all())
|
||||
|
||||
|
||||
async def get_user_ids_with_active_payment_methods(
|
||||
db: AsyncSession,
|
||||
user_ids: list[int],
|
||||
) -> set[int]:
|
||||
"""Вернуть подмножество user_ids, у которых есть хотя бы один активный метод оплаты."""
|
||||
if not user_ids:
|
||||
return set()
|
||||
result = await db.execute(
|
||||
select(SavedPaymentMethod.user_id)
|
||||
.where(
|
||||
SavedPaymentMethod.user_id.in_(user_ids),
|
||||
SavedPaymentMethod.is_active == True,
|
||||
)
|
||||
.distinct()
|
||||
)
|
||||
return set(result.scalars().all())
|
||||
|
||||
|
||||
async def get_payment_method_by_yookassa_id(
|
||||
db: AsyncSession,
|
||||
yookassa_payment_method_id: str,
|
||||
include_inactive: bool = False,
|
||||
) -> SavedPaymentMethod | None:
|
||||
"""Найти сохранённый метод по YooKassa payment_method.id."""
|
||||
query = select(SavedPaymentMethod).where(
|
||||
SavedPaymentMethod.yookassa_payment_method_id == yookassa_payment_method_id,
|
||||
)
|
||||
if not include_inactive:
|
||||
query = query.where(SavedPaymentMethod.is_active == True)
|
||||
result = await db.execute(query)
|
||||
return result.scalar_one_or_none()
|
||||
|
||||
|
||||
async def deactivate_payment_method(
|
||||
db: AsyncSession,
|
||||
saved_method_id: int,
|
||||
user_id: int,
|
||||
) -> bool:
|
||||
"""Деактивировать (soft-delete) сохранённый метод оплаты."""
|
||||
result = await db.execute(
|
||||
update(SavedPaymentMethod)
|
||||
.where(
|
||||
SavedPaymentMethod.id == saved_method_id,
|
||||
SavedPaymentMethod.user_id == user_id,
|
||||
SavedPaymentMethod.is_active == True,
|
||||
)
|
||||
.values(is_active=False, updated_at=datetime.now(UTC))
|
||||
)
|
||||
await db.commit()
|
||||
|
||||
if result.rowcount > 0:
|
||||
logger.info(
|
||||
'Метод оплаты деактивирован',
|
||||
saved_method_id=saved_method_id,
|
||||
user_id=user_id,
|
||||
)
|
||||
return True
|
||||
return False
|
||||
|
||||
|
||||
async def deactivate_all_user_payment_methods(
|
||||
db: AsyncSession,
|
||||
user_id: int,
|
||||
) -> int:
|
||||
"""Деактивировать все методы оплаты пользователя. Возвращает количество деактивированных."""
|
||||
result = await db.execute(
|
||||
update(SavedPaymentMethod)
|
||||
.where(
|
||||
SavedPaymentMethod.user_id == user_id,
|
||||
SavedPaymentMethod.is_active == True,
|
||||
)
|
||||
.values(is_active=False, updated_at=datetime.now(UTC))
|
||||
)
|
||||
await db.commit()
|
||||
|
||||
if result.rowcount > 0:
|
||||
logger.info(
|
||||
'Все методы оплаты пользователя деактивированы',
|
||||
user_id=user_id,
|
||||
count=result.rowcount,
|
||||
)
|
||||
return result.rowcount
|
||||
@@ -306,9 +306,8 @@ async def sync_with_remnawave(db: AsyncSession, remnawave_squads: list[dict]) ->
|
||||
await create_server_squad(
|
||||
db=db,
|
||||
squad_uuid=squad_uuid,
|
||||
display_name=_generate_display_name(original_name),
|
||||
display_name=original_name,
|
||||
original_name=original_name,
|
||||
country_code=_extract_country_code(original_name),
|
||||
price_kopeks=1000,
|
||||
is_available=False,
|
||||
)
|
||||
@@ -482,219 +481,6 @@ async def get_random_trial_squad_uuid(
|
||||
return None
|
||||
|
||||
|
||||
def _generate_display_name(original_name: str) -> str:
|
||||
"""Генерирует отображаемое название сервера на основе оригинального имени."""
|
||||
|
||||
country_names = {
|
||||
# Европа
|
||||
'NL': '🇳🇱 Нидерланды',
|
||||
'DE': '🇩🇪 Германия',
|
||||
'FR': '🇫🇷 Франция',
|
||||
'GB': '🇬🇧 Великобритания',
|
||||
'UK': '🇬🇧 Великобритания',
|
||||
'IT': '🇮🇹 Италия',
|
||||
'ES': '🇪🇸 Испания',
|
||||
'PT': '🇵🇹 Португалия',
|
||||
'PL': '🇵🇱 Польша',
|
||||
'CZ': '🇨🇿 Чехия',
|
||||
'AT': '🇦🇹 Австрия',
|
||||
'CH': '🇨🇭 Швейцария',
|
||||
'SE': '🇸🇪 Швеция',
|
||||
'NO': '🇳🇴 Норвегия',
|
||||
'FI': '🇫🇮 Финляндия',
|
||||
'DK': '🇩🇰 Дания',
|
||||
'BE': '🇧🇪 Бельгия',
|
||||
'IE': '🇮🇪 Ирландия',
|
||||
'RO': '🇷🇴 Румыния',
|
||||
'BG': '🇧🇬 Болгария',
|
||||
'HU': '🇭🇺 Венгрия',
|
||||
'GR': '🇬🇷 Греция',
|
||||
'LV': '🇱🇻 Латвия',
|
||||
'LT': '🇱🇹 Литва',
|
||||
'EE': '🇪🇪 Эстония',
|
||||
'SK': '🇸🇰 Словакия',
|
||||
'SI': '🇸🇮 Словения',
|
||||
'HR': '🇭🇷 Хорватия',
|
||||
'RS': '🇷🇸 Сербия',
|
||||
'UA': '🇺🇦 Украина',
|
||||
'MD': '🇲🇩 Молдова',
|
||||
'BY': '🇧🇾 Беларусь',
|
||||
'LU': '🇱🇺 Люксембург',
|
||||
# СНГ и Азия
|
||||
'RU': '🇷🇺 Россия',
|
||||
'KZ': '🇰🇿 Казахстан',
|
||||
'UZ': '🇺🇿 Узбекистан',
|
||||
'GE': '🇬🇪 Грузия',
|
||||
'AM': '🇦🇲 Армения',
|
||||
'AZ': '🇦🇿 Азербайджан',
|
||||
# Америка
|
||||
'US': '🇺🇸 США',
|
||||
'CA': '🇨🇦 Канада',
|
||||
'MX': '🇲🇽 Мексика',
|
||||
'BR': '🇧🇷 Бразилия',
|
||||
'AR': '🇦🇷 Аргентина',
|
||||
'CL': '🇨🇱 Чили',
|
||||
'CO': '🇨🇴 Колумбия',
|
||||
# Азия
|
||||
'JP': '🇯🇵 Япония',
|
||||
'KR': '🇰🇷 Южная Корея',
|
||||
'CN': '🇨🇳 Китай',
|
||||
'HK': '🇭🇰 Гонконг',
|
||||
'TW': '🇹🇼 Тайвань',
|
||||
'SG': '🇸🇬 Сингапур',
|
||||
'TH': '🇹🇭 Таиланд',
|
||||
'VN': '🇻🇳 Вьетнам',
|
||||
'MY': '🇲🇾 Малайзия',
|
||||
'ID': '🇮🇩 Индонезия',
|
||||
'PH': '🇵🇭 Филиппины',
|
||||
'IN': '🇮🇳 Индия',
|
||||
'PK': '🇵🇰 Пакистан',
|
||||
# Ближний Восток
|
||||
'IL': '🇮🇱 Израиль',
|
||||
'TR': '🇹🇷 Турция',
|
||||
'AE': '🇦🇪 ОАЭ',
|
||||
'SA': '🇸🇦 Саудовская Аравия',
|
||||
'QA': '🇶🇦 Катар',
|
||||
'BH': '🇧🇭 Бахрейн',
|
||||
'KW': '🇰🇼 Кувейт',
|
||||
# Океания
|
||||
'AU': '🇦🇺 Австралия',
|
||||
'NZ': '🇳🇿 Новая Зеландия',
|
||||
# Африка
|
||||
'ZA': '🇿🇦 ЮАР',
|
||||
'EG': '🇪🇬 Египет',
|
||||
'NG': '🇳🇬 Нигерия',
|
||||
'KE': '🇰🇪 Кения',
|
||||
}
|
||||
|
||||
name_upper = original_name.upper()
|
||||
|
||||
# Сначала ищем код как отдельный элемент (через - или _)
|
||||
for code, display_name in country_names.items():
|
||||
if f'-{code}' in name_upper or f'_{code}' in name_upper:
|
||||
return display_name
|
||||
if name_upper.startswith(code + '-') or name_upper.startswith(code + '_'):
|
||||
return display_name
|
||||
if name_upper.endswith('-' + code) or name_upper.endswith('_' + code):
|
||||
return display_name
|
||||
if name_upper == code:
|
||||
return display_name
|
||||
|
||||
# Потом ищем просто вхождение кода
|
||||
for code, display_name in country_names.items():
|
||||
if code in name_upper:
|
||||
return display_name
|
||||
|
||||
return f'🌍 {original_name}'
|
||||
|
||||
|
||||
def _extract_country_code(original_name: str) -> str | None:
|
||||
"""Извлекает код страны из оригинального названия."""
|
||||
|
||||
# Полный список кодов стран
|
||||
codes = [
|
||||
# Европа
|
||||
'NL',
|
||||
'DE',
|
||||
'FR',
|
||||
'GB',
|
||||
'UK',
|
||||
'IT',
|
||||
'ES',
|
||||
'PT',
|
||||
'PL',
|
||||
'CZ',
|
||||
'AT',
|
||||
'CH',
|
||||
'SE',
|
||||
'NO',
|
||||
'FI',
|
||||
'DK',
|
||||
'BE',
|
||||
'IE',
|
||||
'RO',
|
||||
'BG',
|
||||
'HU',
|
||||
'GR',
|
||||
'LV',
|
||||
'LT',
|
||||
'EE',
|
||||
'SK',
|
||||
'SI',
|
||||
'HR',
|
||||
'RS',
|
||||
'UA',
|
||||
'MD',
|
||||
'BY',
|
||||
'LU',
|
||||
# СНГ
|
||||
'RU',
|
||||
'KZ',
|
||||
'UZ',
|
||||
'GE',
|
||||
'AM',
|
||||
'AZ',
|
||||
# Америка
|
||||
'US',
|
||||
'CA',
|
||||
'MX',
|
||||
'BR',
|
||||
'AR',
|
||||
'CL',
|
||||
'CO',
|
||||
# Азия
|
||||
'JP',
|
||||
'KR',
|
||||
'CN',
|
||||
'HK',
|
||||
'TW',
|
||||
'SG',
|
||||
'TH',
|
||||
'VN',
|
||||
'MY',
|
||||
'ID',
|
||||
'PH',
|
||||
'IN',
|
||||
'PK',
|
||||
# Ближний Восток
|
||||
'IL',
|
||||
'TR',
|
||||
'AE',
|
||||
'SA',
|
||||
'QA',
|
||||
'BH',
|
||||
'KW',
|
||||
# Океания
|
||||
'AU',
|
||||
'NZ',
|
||||
# Африка
|
||||
'ZA',
|
||||
'EG',
|
||||
'NG',
|
||||
'KE',
|
||||
]
|
||||
|
||||
name_upper = original_name.upper()
|
||||
|
||||
# Сначала ищем код как отдельный элемент
|
||||
for code in codes:
|
||||
if f'-{code}' in name_upper or f'_{code}' in name_upper:
|
||||
return code
|
||||
if name_upper.startswith(code + '-') or name_upper.startswith(code + '_'):
|
||||
return code
|
||||
if name_upper.endswith('-' + code) or name_upper.endswith('_' + code):
|
||||
return code
|
||||
if name_upper == code:
|
||||
return code
|
||||
|
||||
# Потом просто ищем вхождение
|
||||
for code in codes:
|
||||
if code in name_upper:
|
||||
return code
|
||||
|
||||
return None
|
||||
|
||||
|
||||
async def get_server_statistics(db: AsyncSession) -> dict:
|
||||
total_result = await db.execute(select(func.count(ServerSquad.id)))
|
||||
total_servers = total_result.scalar()
|
||||
|
||||
@@ -21,7 +21,7 @@ from app.database.models import (
|
||||
UserPromoGroup,
|
||||
UserStatus,
|
||||
)
|
||||
from app.utils.pricing_utils import calculate_months_from_days, get_remaining_months
|
||||
from app.utils.pricing_utils import calculate_months_from_days
|
||||
from app.utils.timezone import format_local_datetime
|
||||
|
||||
|
||||
@@ -190,6 +190,7 @@ async def create_paid_subscription(
|
||||
update_server_counters: bool = False,
|
||||
is_trial: bool = False,
|
||||
tariff_id: int | None = None,
|
||||
commit: bool = True,
|
||||
) -> Subscription:
|
||||
end_date = datetime.now(UTC) + timedelta(days=duration_days)
|
||||
|
||||
@@ -211,8 +212,11 @@ async def create_paid_subscription(
|
||||
)
|
||||
|
||||
db.add(subscription)
|
||||
await db.commit()
|
||||
await db.refresh(subscription)
|
||||
if commit:
|
||||
await db.commit()
|
||||
await db.refresh(subscription)
|
||||
else:
|
||||
await db.flush()
|
||||
|
||||
logger.info(
|
||||
'💎 Создана платная подписка для пользователя ID: статус',
|
||||
@@ -265,6 +269,7 @@ async def replace_subscription(
|
||||
autopay_enabled: bool | None = None,
|
||||
autopay_days_before: int | None = None,
|
||||
update_server_counters: bool = False,
|
||||
commit: bool = True,
|
||||
) -> Subscription:
|
||||
"""Перезаписывает параметры существующей подписки пользователя."""
|
||||
|
||||
@@ -297,12 +302,15 @@ async def replace_subscription(
|
||||
subscription.autopay_days_before = new_autopay_days_before
|
||||
subscription.updated_at = current_time
|
||||
|
||||
await db.commit()
|
||||
await db.refresh(subscription)
|
||||
if commit:
|
||||
await db.commit()
|
||||
await db.refresh(subscription)
|
||||
else:
|
||||
await db.flush()
|
||||
|
||||
# Очищаем старые записи об отправленных уведомлениях при замене подписки
|
||||
# (аналогично extend_subscription), чтобы новые уведомления отправлялись корректно
|
||||
await clear_notifications(db, subscription.id)
|
||||
await clear_notifications(db, subscription.id, commit=commit)
|
||||
|
||||
if update_server_counters:
|
||||
try:
|
||||
@@ -764,26 +772,35 @@ async def deactivate_subscription(db: AsyncSession, subscription: Subscription)
|
||||
|
||||
|
||||
async def reactivate_subscription(db: AsyncSession, subscription: Subscription) -> Subscription:
|
||||
"""Реактивация подписки (например, после повторной подписки на канал).
|
||||
"""Реактивация подписки (например, после повторной подписки на канал или докупки трафика).
|
||||
|
||||
Активирует только если подписка была DISABLED и ещё не истекла.
|
||||
Активирует если подписка была DISABLED или EXPIRED и ещё не истекла по времени.
|
||||
Не логирует если реактивация не требуется.
|
||||
"""
|
||||
now = datetime.now(UTC)
|
||||
|
||||
# Тихо выходим если реактивация не нужна
|
||||
if subscription.status != SubscriptionStatus.DISABLED.value:
|
||||
# Тихо выходим если реактивация не нужна (уже активна или другой статус)
|
||||
reactivatable_statuses = {SubscriptionStatus.DISABLED.value, SubscriptionStatus.EXPIRED.value}
|
||||
if subscription.status not in reactivatable_statuses:
|
||||
return subscription
|
||||
|
||||
if subscription.end_date and subscription.end_date <= now:
|
||||
if not subscription.end_date or subscription.end_date <= now:
|
||||
return subscription
|
||||
|
||||
old_status = subscription.status
|
||||
subscription.status = SubscriptionStatus.ACTIVE.value
|
||||
subscription.updated_at = now
|
||||
|
||||
await db.commit()
|
||||
await db.refresh(subscription)
|
||||
|
||||
logger.info(
|
||||
'✅ Подписка реактивирована',
|
||||
subscription_id=subscription.id,
|
||||
user_id=subscription.user_id,
|
||||
old_status=old_status,
|
||||
)
|
||||
|
||||
return subscription
|
||||
|
||||
|
||||
@@ -1114,7 +1131,8 @@ async def add_subscription_servers(
|
||||
await db.refresh(subscription)
|
||||
|
||||
if paid_prices is None:
|
||||
months_remaining = get_remaining_months(subscription.end_date)
|
||||
now = datetime.now(UTC)
|
||||
days_remaining = max(1, (subscription.end_date - now).days)
|
||||
paid_prices = []
|
||||
|
||||
from app.database.models import ServerSquad
|
||||
@@ -1122,7 +1140,7 @@ async def add_subscription_servers(
|
||||
for server_id in server_squad_ids:
|
||||
result = await db.execute(select(ServerSquad.price_kopeks).where(ServerSquad.id == server_id))
|
||||
server_price_per_month = result.scalar() or 0
|
||||
total_price_for_period = server_price_per_month * months_remaining
|
||||
total_price_for_period = int(server_price_per_month * days_remaining / 30)
|
||||
paid_prices.append(total_price_for_period)
|
||||
|
||||
for i, server_id in enumerate(server_squad_ids):
|
||||
@@ -1556,8 +1574,9 @@ async def calculate_addon_cost_for_remaining_period(
|
||||
if additional_server_ids is None:
|
||||
additional_server_ids = []
|
||||
|
||||
months_to_pay = get_remaining_months(subscription.end_date)
|
||||
period_hint_days = months_to_pay * 30 if months_to_pay > 0 else None
|
||||
now = datetime.now(UTC)
|
||||
days_to_pay = max(1, (subscription.end_date - now).days)
|
||||
period_hint_days = days_to_pay
|
||||
|
||||
total_cost = 0
|
||||
|
||||
@@ -1575,11 +1594,13 @@ async def calculate_addon_cost_for_remaining_period(
|
||||
)
|
||||
traffic_discount_per_month = traffic_price_per_month * traffic_discount_percent // 100
|
||||
discounted_traffic_per_month = traffic_price_per_month - traffic_discount_per_month
|
||||
traffic_total_cost = discounted_traffic_per_month * months_to_pay
|
||||
traffic_total_cost = int(discounted_traffic_per_month * days_to_pay / 30)
|
||||
total_cost += traffic_total_cost
|
||||
message = f'Трафик +{additional_traffic_gb}ГБ: {traffic_price_per_month / 100}₽/мес × {months_to_pay} = {traffic_total_cost / 100}₽'
|
||||
message = f'Трафик +{additional_traffic_gb}ГБ: {traffic_price_per_month / 100}₽/мес × {days_to_pay} дн. = {traffic_total_cost / 100}₽'
|
||||
if traffic_discount_per_month > 0:
|
||||
message += f' (скидка {traffic_discount_percent}%: -{traffic_discount_per_month * months_to_pay / 100}₽)'
|
||||
message += (
|
||||
f' (скидка {traffic_discount_percent}%: -{int(traffic_discount_per_month * days_to_pay / 30) / 100}₽)'
|
||||
)
|
||||
logger.info(message)
|
||||
|
||||
if additional_devices > 0:
|
||||
@@ -1592,11 +1613,13 @@ async def calculate_addon_cost_for_remaining_period(
|
||||
)
|
||||
devices_discount_per_month = devices_price_per_month * devices_discount_percent // 100
|
||||
discounted_devices_per_month = devices_price_per_month - devices_discount_per_month
|
||||
devices_total_cost = discounted_devices_per_month * months_to_pay
|
||||
devices_total_cost = int(discounted_devices_per_month * days_to_pay / 30)
|
||||
total_cost += devices_total_cost
|
||||
message = f'Устройства +{additional_devices}: {devices_price_per_month / 100}₽/мес × {months_to_pay} = {devices_total_cost / 100}₽'
|
||||
message = f'Устройства +{additional_devices}: {devices_price_per_month / 100}₽/мес × {days_to_pay} дн. = {devices_total_cost / 100}₽'
|
||||
if devices_discount_per_month > 0:
|
||||
message += f' (скидка {devices_discount_percent}%: -{devices_discount_per_month * months_to_pay / 100}₽)'
|
||||
message += (
|
||||
f' (скидка {devices_discount_percent}%: -{int(devices_discount_per_month * days_to_pay / 30) / 100}₽)'
|
||||
)
|
||||
logger.info(message)
|
||||
|
||||
if additional_server_ids:
|
||||
@@ -1617,16 +1640,14 @@ async def calculate_addon_cost_for_remaining_period(
|
||||
)
|
||||
server_discount_per_month = server_price_per_month * servers_discount_percent // 100
|
||||
discounted_server_per_month = server_price_per_month - server_discount_per_month
|
||||
server_total_cost = discounted_server_per_month * months_to_pay
|
||||
server_total_cost = int(discounted_server_per_month * days_to_pay / 30)
|
||||
total_cost += server_total_cost
|
||||
message = f'Сервер {server_name}: {server_price_per_month / 100}₽/мес × {months_to_pay} = {server_total_cost / 100}₽'
|
||||
message = f'Сервер {server_name}: {server_price_per_month / 100}₽/мес × {days_to_pay} дн. = {server_total_cost / 100}₽'
|
||||
if server_discount_per_month > 0:
|
||||
message += (
|
||||
f' (скидка {servers_discount_percent}%: -{server_discount_per_month * months_to_pay / 100}₽)'
|
||||
)
|
||||
message += f' (скидка {servers_discount_percent}%: -{int(server_discount_per_month * days_to_pay / 30) / 100}₽)'
|
||||
logger.info(message)
|
||||
|
||||
logger.info('💰 Итого доплата за мес: ₽', months_to_pay=months_to_pay, total_cost=total_cost / 100)
|
||||
logger.info('💰 Итого доплата за дн.: ₽', days_to_pay=days_to_pay, total_cost=total_cost / 100)
|
||||
return total_cost
|
||||
|
||||
|
||||
|
||||
@@ -185,6 +185,8 @@ async def create_tariff(
|
||||
traffic_price_per_gb_kopeks: int = 0,
|
||||
min_traffic_gb: int = 1,
|
||||
max_traffic_gb: int = 1000,
|
||||
# Видимость в разделе подарков
|
||||
show_in_gift: bool = True,
|
||||
# Режим сброса трафика
|
||||
traffic_reset_mode: str | None = None, # DAY, WEEK, MONTH, NO_RESET, None = глобальная настройка
|
||||
# Внешний сквад RemnaWave
|
||||
@@ -223,6 +225,8 @@ async def create_tariff(
|
||||
traffic_price_per_gb_kopeks=max(0, traffic_price_per_gb_kopeks),
|
||||
min_traffic_gb=max(1, min_traffic_gb),
|
||||
max_traffic_gb=max(1, max_traffic_gb),
|
||||
# Видимость в разделе подарков
|
||||
show_in_gift=show_in_gift,
|
||||
# Режим сброса трафика
|
||||
traffic_reset_mode=traffic_reset_mode,
|
||||
# Внешний сквад
|
||||
@@ -290,6 +294,8 @@ async def update_tariff(
|
||||
traffic_price_per_gb_kopeks: int | None = None,
|
||||
min_traffic_gb: int | None = None,
|
||||
max_traffic_gb: int | None = None,
|
||||
# Видимость в разделе подарков
|
||||
show_in_gift: bool | None = None,
|
||||
# Режим сброса трафика
|
||||
traffic_reset_mode: str | None = ..., # ... = не передан, None = сбросить к глобальной настройке
|
||||
# Внешний сквад RemnaWave
|
||||
@@ -354,6 +360,9 @@ async def update_tariff(
|
||||
tariff.min_traffic_gb = max(1, min_traffic_gb)
|
||||
if max_traffic_gb is not None:
|
||||
tariff.max_traffic_gb = max(1, max_traffic_gb)
|
||||
# Видимость в разделе подарков
|
||||
if show_in_gift is not None:
|
||||
tariff.show_in_gift = show_in_gift
|
||||
# Режим сброса трафика
|
||||
if traffic_reset_mode is not ...:
|
||||
tariff.traffic_reset_mode = traffic_reset_mode
|
||||
|
||||
@@ -498,6 +498,7 @@ async def subtract_user_balance(
|
||||
create_transaction: bool = False,
|
||||
payment_method: PaymentMethod | None = None,
|
||||
*,
|
||||
transaction_type: TransactionType = TransactionType.WITHDRAWAL,
|
||||
consume_promo_offer: bool = False,
|
||||
mark_as_paid_subscription: bool = False,
|
||||
) -> bool:
|
||||
@@ -576,7 +577,7 @@ async def subtract_user_balance(
|
||||
await create_trans(
|
||||
db=db,
|
||||
user_id=user.id,
|
||||
type=TransactionType.WITHDRAWAL,
|
||||
type=transaction_type,
|
||||
amount_kopeks=amount_kopeks,
|
||||
description=description,
|
||||
payment_method=payment_method,
|
||||
|
||||
@@ -40,6 +40,7 @@ async def _sync_user_primary_promo_group(
|
||||
|
||||
except Exception as error:
|
||||
logger.error('Ошибка синхронизации primary промогруппы пользователя', user_id=user_id, error=error)
|
||||
raise
|
||||
|
||||
|
||||
async def sync_user_primary_promo_group(
|
||||
@@ -187,7 +188,7 @@ async def get_primary_user_promo_group(db: AsyncSession, user_id: int) -> PromoG
|
||||
return None
|
||||
|
||||
# Первая в списке имеет максимальный приоритет (список уже отсортирован)
|
||||
return user_promo_groups[0].promo_group if user_promo_groups[0].promo_group else None
|
||||
return user_promo_groups[0].promo_group or None
|
||||
|
||||
except Exception as error:
|
||||
logger.error('Ошибка получения primary промогруппы пользователя', user_id=user_id, error=error)
|
||||
|
||||
+107
-7
@@ -8,7 +8,7 @@ def _aware(dt: datetime | None) -> datetime | None:
|
||||
return dt
|
||||
|
||||
|
||||
from enum import Enum
|
||||
from enum import Enum, StrEnum
|
||||
|
||||
from sqlalchemy import (
|
||||
JSON,
|
||||
@@ -157,6 +157,7 @@ class PaymentMethod(Enum):
|
||||
CLOUDPAYMENTS = 'cloudpayments'
|
||||
FREEKASSA = 'freekassa'
|
||||
KASSA_AI = 'kassa_ai'
|
||||
RIOPAY = 'riopay'
|
||||
MANUAL = 'manual'
|
||||
BALANCE = 'balance'
|
||||
|
||||
@@ -238,6 +239,38 @@ class YooKassaPayment(Base):
|
||||
return f'<YooKassaPayment(id={self.id}, yookassa_id={self.yookassa_payment_id}, amount={self.amount_rubles}₽, status={self.status})>'
|
||||
|
||||
|
||||
class SavedPaymentMethod(Base):
|
||||
__tablename__ = 'saved_payment_methods'
|
||||
__table_args__ = (Index('ix_saved_payment_methods_user_active', 'user_id', 'is_active'),)
|
||||
|
||||
id = Column(Integer, primary_key=True, index=True)
|
||||
user_id = Column(Integer, ForeignKey('users.id'), nullable=False, index=True)
|
||||
|
||||
# YooKassa payment_method.id — ключ для рекуррентных списаний
|
||||
yookassa_payment_method_id = Column(String(255), unique=True, nullable=False, index=True)
|
||||
|
||||
# Тип метода: bank_card, yoo_money, sberbank, tinkoff_bank, sbp, mir_pay
|
||||
method_type = Column(String(50), nullable=False, default='bank_card')
|
||||
|
||||
# Отображаемые данные карты (маскированные)
|
||||
card_first6 = Column(String(6), nullable=True)
|
||||
card_last4 = Column(String(4), nullable=True)
|
||||
card_type = Column(String(50), nullable=True) # Visa, MasterCard, Mir
|
||||
card_expiry_month = Column(String(2), nullable=True)
|
||||
card_expiry_year = Column(String(4), nullable=True)
|
||||
title = Column(String(255), nullable=True) # "Bank card *4444"
|
||||
|
||||
is_active = Column(Boolean, default=True)
|
||||
|
||||
created_at = Column(AwareDateTime(), nullable=False, server_default=func.now())
|
||||
updated_at = Column(AwareDateTime(), nullable=False, server_default=func.now(), onupdate=func.now())
|
||||
|
||||
user = relationship('User', backref='saved_payment_methods')
|
||||
|
||||
def __repr__(self):
|
||||
return f'<SavedPaymentMethod(id={self.id}, user_id={self.user_id}, type={self.method_type}, last4={self.card_last4})>'
|
||||
|
||||
|
||||
class CryptoBotPayment(Base):
|
||||
__tablename__ = 'cryptobot_payments'
|
||||
|
||||
@@ -716,6 +749,68 @@ class KassaAiPayment(Base):
|
||||
return f'<KassaAiPayment(id={self.id}, order_id={self.order_id}, amount={self.amount_rubles}₽, status={self.status})>'
|
||||
|
||||
|
||||
class RioPayPayment(Base):
|
||||
"""Платежи через RioPay (api.riopay.online)."""
|
||||
|
||||
__tablename__ = 'riopay_payments'
|
||||
|
||||
id = Column(Integer, primary_key=True, index=True)
|
||||
user_id = Column(Integer, ForeignKey('users.id'), nullable=False, index=True)
|
||||
|
||||
# Идентификаторы
|
||||
order_id = Column(String(64), unique=True, nullable=False, index=True) # Наш internal ID
|
||||
riopay_order_id = Column(String(64), unique=True, nullable=True, index=True) # UUID от RioPay
|
||||
|
||||
# Суммы
|
||||
amount_kopeks = Column(Integer, nullable=False)
|
||||
currency = Column(String(10), nullable=False, default='RUB')
|
||||
description = Column(Text, nullable=True)
|
||||
|
||||
# Статусы
|
||||
status = Column(String(32), nullable=False, default='pending') # pending, success, failed, expired, canceled
|
||||
is_paid = Column(Boolean, default=False)
|
||||
|
||||
# Данные платежа
|
||||
payment_url = Column(Text, nullable=True)
|
||||
payment_method = Column(String(32), nullable=True) # CARD, SBP
|
||||
|
||||
# Метаданные
|
||||
metadata_json = Column(JSON, nullable=True)
|
||||
callback_payload = Column(JSON, nullable=True)
|
||||
|
||||
# Временные метки
|
||||
paid_at = Column(AwareDateTime(), nullable=True)
|
||||
expires_at = Column(AwareDateTime(), nullable=True)
|
||||
created_at = Column(AwareDateTime(), default=func.now())
|
||||
updated_at = Column(AwareDateTime(), default=func.now(), onupdate=func.now())
|
||||
|
||||
# Связь с транзакцией
|
||||
transaction_id = Column(Integer, ForeignKey('transactions.id'), nullable=True)
|
||||
|
||||
# Relationships
|
||||
user = relationship('User', backref='riopay_payments')
|
||||
transaction = relationship('Transaction', backref='riopay_payment')
|
||||
|
||||
@property
|
||||
def amount_rubles(self) -> float:
|
||||
return self.amount_kopeks / 100
|
||||
|
||||
@property
|
||||
def is_pending(self) -> bool:
|
||||
return self.status == 'pending'
|
||||
|
||||
@property
|
||||
def is_success(self) -> bool:
|
||||
return self.status == 'success' and self.is_paid
|
||||
|
||||
@property
|
||||
def is_failed(self) -> bool:
|
||||
return self.status in ['failed', 'expired', 'canceled']
|
||||
|
||||
def __repr__(self) -> str: # pragma: no cover - debug helper
|
||||
return f'<RioPayPayment(id={self.id}, order_id={self.order_id}, amount={self.amount_rubles}₽, status={self.status})>'
|
||||
|
||||
|
||||
class PromoGroup(Base):
|
||||
__tablename__ = 'promo_groups'
|
||||
|
||||
@@ -879,6 +974,9 @@ class Tariff(Base):
|
||||
min_traffic_gb = Column(Integer, default=1, nullable=False) # Минимальный трафик в ГБ
|
||||
max_traffic_gb = Column(Integer, default=1000, nullable=False) # Максимальный трафик в ГБ
|
||||
|
||||
# Видимость в разделе подарков
|
||||
show_in_gift = Column(Boolean, default=True, server_default='true', nullable=False)
|
||||
|
||||
# Режим сброса трафика: DAY, WEEK, MONTH, NO_RESET (по умолчанию берётся из конфига)
|
||||
traffic_reset_mode = Column(String(20), nullable=True, default=None) # None = использовать глобальную настройку
|
||||
|
||||
@@ -1049,7 +1147,9 @@ class User(Base):
|
||||
discord_id = Column(String(255), unique=True, nullable=True, index=True)
|
||||
vk_id = Column(BigInteger, unique=True, nullable=True, index=True)
|
||||
broadcasts = relationship('BroadcastHistory', back_populates='admin')
|
||||
referrals = relationship('User', backref='referrer', remote_side=[id], foreign_keys='User.referred_by_id')
|
||||
referrals = relationship(
|
||||
'User', backref='referrer', remote_side=[id], foreign_keys='User.referred_by_id', post_update=True
|
||||
)
|
||||
subscription = relationship('Subscription', back_populates='user', uselist=False)
|
||||
transactions = relationship('Transaction', back_populates='user')
|
||||
referral_earnings = relationship('ReferralEarning', foreign_keys='ReferralEarning.user_id', back_populates='user')
|
||||
@@ -1124,10 +1224,10 @@ class User(Base):
|
||||
|
||||
def get_primary_promo_group(self):
|
||||
"""Возвращает промогруппу с максимальным приоритетом."""
|
||||
if not self.user_promo_groups:
|
||||
return getattr(self, 'promo_group', None)
|
||||
|
||||
try:
|
||||
if not self.user_promo_groups:
|
||||
return getattr(self, 'promo_group', None)
|
||||
|
||||
# Сортируем по приоритету группы (убывание), затем по ID группы
|
||||
# Используем getattr для защиты от ленивой загрузки
|
||||
sorted_groups = sorted(
|
||||
@@ -1139,7 +1239,7 @@ class User(Base):
|
||||
if sorted_groups and sorted_groups[0].promo_group:
|
||||
return sorted_groups[0].promo_group
|
||||
except Exception:
|
||||
# Если возникла ошибка (например, ленивая загрузка), fallback на старую связь
|
||||
# Если возникла ошибка (например, ленивая загрузка в async), fallback на старую связь
|
||||
pass
|
||||
|
||||
# Fallback на старую связь если новая пустая или возникла ошибка
|
||||
@@ -3061,7 +3161,7 @@ class LandingPage(Base):
|
||||
return f"<LandingPage slug='{self.slug}' active={self.is_active}>"
|
||||
|
||||
|
||||
class GuestPurchaseStatus(str, Enum):
|
||||
class GuestPurchaseStatus(StrEnum):
|
||||
PENDING = 'pending'
|
||||
PAID = 'paid'
|
||||
DELIVERED = 'delivered'
|
||||
|
||||
@@ -29,7 +29,7 @@ async def show_blacklist_settings(callback: types.CallbackQuery, db_user: User,
|
||||
blacklist_count = len(await blacklist_service.get_all_blacklisted_users())
|
||||
|
||||
status_text = '✅ Включена' if is_enabled else '❌ Отключена'
|
||||
url_text = github_url if github_url else 'Не задан'
|
||||
url_text = github_url or 'Не задан'
|
||||
|
||||
text = f"""
|
||||
🔐 <b>Настройки черного списка</b>
|
||||
|
||||
@@ -63,7 +63,7 @@ CATEGORY_GROUP_METADATA: dict[str, dict[str, object]] = {
|
||||
},
|
||||
'payments': {
|
||||
'title': '💳 Платежные системы',
|
||||
'description': 'YooKassa, CryptoBot, Heleket, CloudPayments, Freekassa, MulenPay, PAL24, Wata, Platega, Tribute, Kassa AI и Telegram Stars.',
|
||||
'description': 'YooKassa, CryptoBot, Heleket, CloudPayments, Freekassa, MulenPay, PAL24, Wata, Platega, Tribute, Kassa AI, RioPay и Telegram Stars.',
|
||||
'icon': '💳',
|
||||
'categories': (
|
||||
'PAYMENT',
|
||||
@@ -74,6 +74,7 @@ CATEGORY_GROUP_METADATA: dict[str, dict[str, object]] = {
|
||||
'CLOUDPAYMENTS',
|
||||
'FREEKASSA',
|
||||
'KASSA_AI',
|
||||
'RIOPAY',
|
||||
'MULENPAY',
|
||||
'PAL24',
|
||||
'WATA',
|
||||
@@ -263,6 +264,7 @@ def _get_group_status(group_key: str) -> tuple[str, str]:
|
||||
'CloudPayments': settings.is_cloudpayments_enabled(),
|
||||
'Freekassa': settings.is_freekassa_enabled(),
|
||||
'Kassa AI': settings.is_kassa_ai_enabled(),
|
||||
'RioPay': settings.is_riopay_enabled(),
|
||||
'MulenPay': settings.is_mulenpay_enabled(),
|
||||
'PAL24': settings.is_pal24_enabled(),
|
||||
'Tribute': settings.TRIBUTE_ENABLED,
|
||||
@@ -1251,6 +1253,9 @@ def _build_settings_keyboard(
|
||||
elif category_key == 'KASSA_AI':
|
||||
label = texts.t('PAYMENT_KASSA_AI', f'💳 {settings.get_kassa_ai_display_name()}')
|
||||
test_payment_buttons.append([_test_button(f'{label} · тест', 'kassa_ai')])
|
||||
elif category_key == 'RIOPAY':
|
||||
label = texts.t('PAYMENT_RIOPAY', f'💳 {settings.get_riopay_display_name()}')
|
||||
test_payment_buttons.append([_test_button(f'{label} · тест', 'riopay')])
|
||||
|
||||
if test_payment_buttons:
|
||||
rows.extend(test_payment_buttons)
|
||||
@@ -2325,6 +2330,48 @@ async def test_payment_provider(
|
||||
await _refresh_markup()
|
||||
return
|
||||
|
||||
if method == 'riopay':
|
||||
if not settings.is_riopay_enabled():
|
||||
await callback.answer('❌ RioPay отключена', show_alert=True)
|
||||
return
|
||||
|
||||
amount_kopeks = settings.RIOPAY_MIN_AMOUNT_KOPEKS
|
||||
payment_result = await payment_service.create_riopay_payment(
|
||||
db=db,
|
||||
user_id=db_user.id,
|
||||
amount_kopeks=amount_kopeks,
|
||||
description='Тестовый платеж RioPay (админ)',
|
||||
email=getattr(db_user, 'email', None),
|
||||
language=db_user.language or settings.DEFAULT_LANGUAGE,
|
||||
)
|
||||
|
||||
if not payment_result or not payment_result.get('payment_url'):
|
||||
await callback.answer('❌ Не удалось создать тестовый платеж RioPay', show_alert=True)
|
||||
await _refresh_markup()
|
||||
return
|
||||
|
||||
payment_url = payment_result['payment_url']
|
||||
display_name = settings.get_riopay_display_name()
|
||||
message_text = (
|
||||
f'🧪 <b>Тестовый платеж {display_name}</b>\n\n'
|
||||
f'💰 Сумма: {texts.format_price(amount_kopeks)}\n'
|
||||
f'🆔 Order ID: {payment_result["order_id"]}'
|
||||
)
|
||||
reply_markup = types.InlineKeyboardMarkup(
|
||||
inline_keyboard=[
|
||||
[
|
||||
types.InlineKeyboardButton(
|
||||
text='💳 Перейти к оплате',
|
||||
url=payment_url,
|
||||
)
|
||||
]
|
||||
]
|
||||
)
|
||||
await callback.message.answer(message_text, reply_markup=reply_markup, parse_mode='HTML')
|
||||
await callback.answer(f'✅ Ссылка на платеж {display_name} отправлена', show_alert=True)
|
||||
await _refresh_markup()
|
||||
return
|
||||
|
||||
await callback.answer('❌ Неизвестный способ тестирования платежа', show_alert=True)
|
||||
await _refresh_markup()
|
||||
|
||||
|
||||
@@ -1350,12 +1350,12 @@ async def _do_reconcile_logs(callback: CallbackQuery):
|
||||
await callback.answer('🔄 Анализирую логи платежей...', show_alert=False)
|
||||
|
||||
# Путь к файлу логов платежей (logs/current/)
|
||||
log_file_path = Path(settings.LOG_FILE).resolve()
|
||||
log_file_path = await asyncio.to_thread(Path(settings.LOG_FILE).resolve)
|
||||
log_dir = log_file_path.parent
|
||||
current_dir = log_dir / 'current'
|
||||
payments_log = current_dir / settings.LOG_PAYMENTS_FILE
|
||||
|
||||
if not payments_log.exists():
|
||||
if not await asyncio.to_thread(payments_log.exists):
|
||||
try:
|
||||
await callback.message.edit_text(
|
||||
'❌ <b>Файл логов не найден</b>\n\n'
|
||||
@@ -1491,12 +1491,12 @@ async def receipts_reconcile_logs_details_callback(callback: CallbackQuery):
|
||||
await callback.answer('🔄 Загружаю детали...', show_alert=False)
|
||||
|
||||
# Путь к логам (logs/current/)
|
||||
log_file_path = Path(settings.LOG_FILE).resolve()
|
||||
log_file_path = await asyncio.to_thread(Path(settings.LOG_FILE).resolve)
|
||||
log_dir = log_file_path.parent
|
||||
current_dir = log_dir / 'current'
|
||||
payments_log = current_dir / settings.LOG_PAYMENTS_FILE
|
||||
|
||||
if not payments_log.exists():
|
||||
if not await asyncio.to_thread(payments_log.exists):
|
||||
await callback.answer('❌ Файл логов не найден', show_alert=True)
|
||||
return
|
||||
|
||||
|
||||
@@ -48,6 +48,8 @@ def _method_display(method: PaymentMethod) -> str:
|
||||
return 'Telegram Stars'
|
||||
if method == PaymentMethod.KASSA_AI:
|
||||
return settings.get_kassa_ai_display_name()
|
||||
if method == PaymentMethod.RIOPAY:
|
||||
return settings.get_riopay_display_name()
|
||||
if method == PaymentMethod.FREEKASSA:
|
||||
return settings.get_freekassa_display_name()
|
||||
return method.value
|
||||
@@ -186,7 +188,7 @@ def _is_checkable(record: PendingPayment) -> bool:
|
||||
if record.method == PaymentMethod.YOOKASSA:
|
||||
return status in {'pending', 'waiting_for_capture'}
|
||||
if record.method == PaymentMethod.CRYPTOBOT:
|
||||
return status in {'active'}
|
||||
return status == 'active'
|
||||
if record.method == PaymentMethod.FREEKASSA:
|
||||
return status in {'pending', 'created', ''}
|
||||
if record.method == PaymentMethod.KASSA_AI:
|
||||
@@ -378,7 +380,7 @@ def _build_payment_details_text(record: PendingPayment, *, texts, language: str)
|
||||
amount = f'{crypto_amount} {crypto_asset}'
|
||||
created = format_datetime(record.created_at)
|
||||
age = format_time_ago(record.created_at, language)
|
||||
raw_identifier = record.identifier if record.identifier else record.local_id
|
||||
raw_identifier = record.identifier or record.local_id
|
||||
identifier = html.escape(str(raw_identifier)) if raw_identifier is not None else '—'
|
||||
lines = [
|
||||
texts.t('ADMIN_PAYMENT_DETAILS_TITLE', '💳 <b>Payment details</b>'),
|
||||
|
||||
@@ -977,7 +977,7 @@ async def _render_squad_selection(
|
||||
if not selected_server:
|
||||
selected_server = await get_server_squad_by_uuid(db, selected_uuid)
|
||||
if selected_server:
|
||||
selected_server_name = selected_server.display_name
|
||||
selected_server_name = html.escape(selected_server.display_name)
|
||||
|
||||
header = texts.t('ADMIN_PROMO_OFFER_SELECT_SQUAD_TITLE', '🌍 <b>Выберите сквад</b>')
|
||||
if selected_server_name:
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import asyncio
|
||||
import json
|
||||
from datetime import UTC, datetime, timedelta
|
||||
|
||||
@@ -756,8 +757,8 @@ async def _show_diagnostics_for_period(callback: types.CallbackQuery, db: AsyncS
|
||||
|
||||
# Информация о логах
|
||||
log_path = referral_diagnostics_service.log_path
|
||||
log_exists = log_path.exists()
|
||||
log_size = log_path.stat().st_size if log_exists else 0
|
||||
log_exists = await asyncio.to_thread(log_path.exists)
|
||||
log_size = (await asyncio.to_thread(log_path.stat)).st_size if log_exists else 0
|
||||
|
||||
text += f'\n<i>📂 {log_path.name}'
|
||||
if log_exists:
|
||||
@@ -1434,9 +1435,9 @@ async def receive_log_file(message: types.Message, db_user: User, db: AsyncSessi
|
||||
|
||||
finally:
|
||||
# Удаляем временный файл
|
||||
if temp_file_path and Path(temp_file_path).exists():
|
||||
if temp_file_path and await asyncio.to_thread(Path(temp_file_path).exists):
|
||||
try:
|
||||
Path(temp_file_path).unlink()
|
||||
await asyncio.to_thread(Path(temp_file_path).unlink)
|
||||
logger.info('🗑️ Временный файл удалён', temp_file_path=temp_file_path)
|
||||
except Exception as e:
|
||||
logger.error('Ошибка удаления временного файла', error=e)
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import html
|
||||
import math
|
||||
from datetime import UTC, datetime, timedelta
|
||||
from typing import Any
|
||||
@@ -175,7 +176,7 @@ def _format_migration_server_label(texts, server) -> str:
|
||||
return texts.t(
|
||||
'ADMIN_SQUAD_MIGRATION_SERVER_LABEL',
|
||||
'{name} — 👥 {users} ({status})',
|
||||
).format(name=server.display_name, users=server.current_users, status=status)
|
||||
).format(name=html.escape(server.display_name), users=server.current_users, status=status)
|
||||
|
||||
|
||||
def _build_migration_keyboard(
|
||||
|
||||
@@ -44,8 +44,8 @@ def _build_server_edit_view(server):
|
||||
<b>Информация:</b>
|
||||
• ID: {server.id}
|
||||
• UUID: <code>{server.squad_uuid}</code>
|
||||
• Название: {server.display_name}
|
||||
• Оригинальное: {server.original_name or 'Не указано'}
|
||||
• Название: {html.escape(server.display_name)}
|
||||
• Оригинальное: {html.escape(server.original_name) if server.original_name else 'Не указано'}
|
||||
• Статус: {status_emoji}
|
||||
|
||||
<b>Настройки:</b>
|
||||
@@ -172,7 +172,7 @@ async def show_servers_list(callback: types.CallbackQuery, db_user: User, db: As
|
||||
status_emoji = '✅' if server.is_available else '❌'
|
||||
price_text = f'{int(server.price_rubles)} ₽' if server.price_kopeks > 0 else 'Бесплатно'
|
||||
|
||||
text += f'{i}. {status_emoji} {server.display_name}\n'
|
||||
text += f'{i}. {status_emoji} {html.escape(server.display_name)}\n'
|
||||
text += f' 💰 Цена: {price_text}'
|
||||
|
||||
if server.max_users:
|
||||
@@ -559,7 +559,7 @@ async def start_server_edit_name(callback: types.CallbackQuery, state: FSMContex
|
||||
|
||||
await callback.message.edit_text(
|
||||
f'✏️ <b>Редактирование названия</b>\n\n'
|
||||
f'Текущее название: <b>{server.display_name}</b>\n\n'
|
||||
f'Текущее название: <b>{html.escape(server.display_name)}</b>\n\n'
|
||||
f'Отправьте новое название для сервера:',
|
||||
reply_markup=types.InlineKeyboardMarkup(
|
||||
inline_keyboard=[
|
||||
@@ -621,7 +621,7 @@ async def delete_server_confirm(callback: types.CallbackQuery, db_user: User, db
|
||||
🗑️ <b>Удаление сервера</b>
|
||||
|
||||
Вы действительно хотите удалить сервер:
|
||||
<b>{server.display_name}</b>
|
||||
<b>{html.escape(server.display_name)}</b>
|
||||
|
||||
⚠️ <b>Внимание!</b>
|
||||
Сервер можно удалить только если к нему нет активных подключений.
|
||||
@@ -658,7 +658,7 @@ async def delete_server_execute(callback: types.CallbackQuery, db_user: User, db
|
||||
await cache.delete_pattern('available_countries*')
|
||||
|
||||
await callback.message.edit_text(
|
||||
f'✅ Сервер <b>{server.display_name}</b> успешно удален!',
|
||||
f'✅ Сервер <b>{html.escape(server.display_name)}</b> успешно удален!',
|
||||
reply_markup=types.InlineKeyboardMarkup(
|
||||
inline_keyboard=[
|
||||
[types.InlineKeyboardButton(text='📋 К списку серверов', callback_data='admin_servers_list')]
|
||||
@@ -668,7 +668,7 @@ async def delete_server_execute(callback: types.CallbackQuery, db_user: User, db
|
||||
)
|
||||
else:
|
||||
await callback.message.edit_text(
|
||||
f'❌ Не удалось удалить сервер <b>{server.display_name}</b>\n\nВозможно, к нему есть активные подключения.',
|
||||
f'❌ Не удалось удалить сервер <b>{html.escape(server.display_name)}</b>\n\nВозможно, к нему есть активные подключения.',
|
||||
reply_markup=types.InlineKeyboardMarkup(
|
||||
inline_keyboard=[
|
||||
[types.InlineKeyboardButton(text='🔙 К серверу', callback_data=f'admin_server_edit_{server_id}')]
|
||||
@@ -706,7 +706,7 @@ async def show_server_detailed_stats(callback: types.CallbackQuery, db_user: Use
|
||||
|
||||
for i, server in enumerate(sorted_servers[:5], 1):
|
||||
price_text = f'{int(server.price_rubles)} ₽' if server.price_kopeks > 0 else 'Бесплатно'
|
||||
text += f'{i}. {server.display_name} - {price_text}\n'
|
||||
text += f'{i}. {html.escape(server.display_name)} - {price_text}\n'
|
||||
|
||||
if not sorted_servers:
|
||||
text += 'Нет доступных серверов\n'
|
||||
@@ -968,7 +968,7 @@ async def start_server_edit_promo_groups(
|
||||
|
||||
text = (
|
||||
'🎯 <b>Настройка промогрупп</b>\n\n'
|
||||
f'Сервер: <b>{server.display_name}</b>\n\n'
|
||||
f'Сервер: <b>{html.escape(server.display_name)}</b>\n\n'
|
||||
'Выберите промогруппы, которым будет доступен этот сервер.\n'
|
||||
'Должна быть выбрана минимум одна промогруппа.'
|
||||
)
|
||||
|
||||
@@ -81,7 +81,7 @@ def _split_text_into_pages(header: str, message_blocks: list[str], max_len: int
|
||||
if current.strip():
|
||||
pages.append(current)
|
||||
|
||||
return pages if pages else [header]
|
||||
return pages or [header]
|
||||
|
||||
|
||||
async def show_admin_tickets(callback: types.CallbackQuery, db_user: User, db: AsyncSession):
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import html
|
||||
import re
|
||||
from dataclasses import dataclass
|
||||
from datetime import UTC, datetime, timedelta
|
||||
@@ -868,7 +869,7 @@ async def _render_user_subscription_overview(callback: types.CallbackQuery, db:
|
||||
try:
|
||||
server = await get_server_squad_by_uuid(db, squad_uuid)
|
||||
if server:
|
||||
text += f'• {server.display_name}\n'
|
||||
text += f'• {html.escape(server.display_name)}\n'
|
||||
else:
|
||||
text += f'• {squad_uuid[:8]}... (неизвестный)\n'
|
||||
except Exception as e:
|
||||
@@ -4002,12 +4003,20 @@ async def _add_subscription_traffic(db: AsyncSession, user_id: int, gb: int, adm
|
||||
else:
|
||||
await add_subscription_traffic(db, subscription, gb)
|
||||
|
||||
# Реактивируем подписку если она была DISABLED (например, после LIMITED в RemnaWave)
|
||||
# Реактивируем подписку если она была DISABLED/EXPIRED (например, после LIMITED/EXPIRED в RemnaWave)
|
||||
await reactivate_subscription(db, subscription)
|
||||
|
||||
subscription_service = SubscriptionService()
|
||||
await subscription_service.update_remnawave_user(db, subscription)
|
||||
|
||||
# Явно включаем пользователя на панели (PATCH может не снять LIMITED-статус)
|
||||
if subscription.status == 'active':
|
||||
from app.database.crud.user import get_user_by_id
|
||||
|
||||
user = await get_user_by_id(db, user_id)
|
||||
if user and user.remnawave_uuid:
|
||||
await subscription_service.enable_remnawave_user(user.remnawave_uuid)
|
||||
|
||||
traffic_text = 'безлимитный' if gb == 0 else f'{gb} ГБ'
|
||||
logger.info('Админ добавил трафик пользователю', admin_id=admin_id, traffic_text=traffic_text, user_id=user_id)
|
||||
return True
|
||||
@@ -5309,9 +5318,24 @@ async def confirm_admin_tariff_change(callback: types.CallbackQuery, db_user: Us
|
||||
try:
|
||||
old_tariff_id = subscription.tariff_id
|
||||
|
||||
# Обновляем параметры подписки в соответствии с тарифом
|
||||
# Preserve extra purchased devices above the old tariff's base limit
|
||||
extra_devices = 0
|
||||
if subscription.tariff_id:
|
||||
old_tariff = await get_tariff_by_id(db, subscription.tariff_id)
|
||||
if old_tariff and old_tariff.device_limit:
|
||||
extra_devices = max(0, (subscription.device_limit or old_tariff.device_limit) - old_tariff.device_limit)
|
||||
|
||||
subscription.tariff_id = tariff.id
|
||||
subscription.device_limit = tariff.device_limit
|
||||
|
||||
new_base = tariff.device_limit or 1
|
||||
new_total = new_base + extra_devices
|
||||
effective_max = tariff.max_device_limit or (
|
||||
settings.MAX_DEVICES_LIMIT if settings.MAX_DEVICES_LIMIT > 0 else None
|
||||
)
|
||||
if effective_max and new_total > effective_max:
|
||||
new_total = effective_max
|
||||
subscription.device_limit = new_total
|
||||
|
||||
subscription.traffic_limit_gb = tariff.traffic_limit_gb
|
||||
subscription.connected_squads = tariff.allowed_squads or []
|
||||
subscription.updated_at = datetime.now(UTC)
|
||||
@@ -5329,6 +5353,18 @@ async def confirm_admin_tariff_change(callback: types.CallbackQuery, db_user: Us
|
||||
if settings.RESET_TRAFFIC_ON_TARIFF_SWITCH:
|
||||
subscription.traffic_used_gb = 0.0
|
||||
|
||||
# Записываем транзакцию о смене тарифа
|
||||
from app.database.crud.transaction import create_transaction
|
||||
|
||||
await create_transaction(
|
||||
db=db,
|
||||
user_id=user.id,
|
||||
type=TransactionType.SUBSCRIPTION_PAYMENT,
|
||||
amount_kopeks=0,
|
||||
description=f"Смена тарифа администратором на '{tariff.name}'",
|
||||
commit=False,
|
||||
)
|
||||
|
||||
await db.commit()
|
||||
|
||||
# Синхронизируем с RemnaWave (сброс трафика по админ-настройке)
|
||||
@@ -5352,7 +5388,7 @@ async def confirm_admin_tariff_change(callback: types.CallbackQuery, db_user: Us
|
||||
await callback.message.edit_text(
|
||||
f'✅ <b>Тариф успешно изменен</b>\n\n'
|
||||
f'Новый тариф: <b>{tariff.name}</b>\n'
|
||||
f'• Устройства: {tariff.device_limit}\n'
|
||||
f'• Устройства: {subscription.device_limit}\n'
|
||||
f'• Трафик: {"♾️" if tariff.traffic_limit_gb == 0 else f"{tariff.traffic_limit_gb} ГБ"}\n'
|
||||
f'• Серверы: {len(tariff.allowed_squads) if tariff.allowed_squads else 0}',
|
||||
reply_markup=types.InlineKeyboardMarkup(
|
||||
|
||||
@@ -21,8 +21,8 @@ logger = structlog.get_logger(__name__)
|
||||
|
||||
|
||||
FREEKASSA_SUB_METHODS = {
|
||||
'freekassa_sbp': {'payment_system_id': 44, 'get_name': lambda: settings.get_freekassa_sbp_display_name()},
|
||||
'freekassa_card': {'payment_system_id': 36, 'get_name': lambda: settings.get_freekassa_card_display_name()},
|
||||
'freekassa_sbp': {'payment_system_id': 44, 'get_name': settings.get_freekassa_sbp_display_name},
|
||||
'freekassa_card': {'payment_system_id': 36, 'get_name': settings.get_freekassa_card_display_name},
|
||||
}
|
||||
|
||||
|
||||
|
||||
+13
-136
@@ -138,6 +138,13 @@ async def route_payment_by_method(
|
||||
await process_kassa_ai_payment_amount(message, db_user, db, amount_kopeks, state)
|
||||
return True
|
||||
|
||||
if payment_method == 'riopay':
|
||||
from .riopay import process_riopay_payment_amount
|
||||
|
||||
async with AsyncSessionLocal() as db:
|
||||
await process_riopay_payment_amount(message, db_user, db, amount_kopeks, state)
|
||||
return True
|
||||
|
||||
return False
|
||||
|
||||
|
||||
@@ -397,10 +404,7 @@ async def handle_balance_history_pagination(callback: types.CallbackQuery, db_us
|
||||
@error_handler
|
||||
async def show_payment_methods(callback: types.CallbackQuery, db_user: User, db: AsyncSession, state: FSMContext):
|
||||
from app.config import settings
|
||||
from app.database.crud.subscription import get_subscription_by_user_id
|
||||
from app.services.subscription_service import SubscriptionService
|
||||
from app.utils.payment_utils import get_payment_methods_text
|
||||
from app.utils.pricing_utils import apply_percentage_discount, calculate_months_from_days
|
||||
|
||||
texts = get_texts(db_user.language)
|
||||
|
||||
@@ -423,139 +427,7 @@ async def show_payment_methods(callback: types.CallbackQuery, db_user: User, db:
|
||||
|
||||
payment_text = get_payment_methods_text(db_user.language)
|
||||
|
||||
# Добавляем информацию о текущем тарифе пользователя
|
||||
subscription = await get_subscription_by_user_id(db, db_user.id)
|
||||
tariff_info = ''
|
||||
if subscription and not subscription.is_trial:
|
||||
# Рассчитываем приблизительную стоимость продления на 30 дней
|
||||
duration_days = 30 # Берем для примера 30 дней
|
||||
current_traffic = subscription.traffic_limit_gb
|
||||
current_connected_squads = subscription.connected_squads or []
|
||||
current_device_limit = subscription.device_limit or settings.DEFAULT_DEVICE_LIMIT
|
||||
|
||||
try:
|
||||
# Получаем цены для текущих параметров
|
||||
from app.config import PERIOD_PRICES
|
||||
from app.database.crud.tariff import get_tariff_by_id
|
||||
|
||||
# В режиме тарифов берём цену из тарифа пользователя
|
||||
tariff = None
|
||||
tariff_price_found = False
|
||||
base_price_original = 0
|
||||
if settings.is_tariffs_mode() and subscription.tariff_id:
|
||||
tariff = await get_tariff_by_id(db, subscription.tariff_id)
|
||||
if tariff and tariff.period_prices:
|
||||
base_price_original = tariff.period_prices.get(str(duration_days), 0)
|
||||
if base_price_original > 0:
|
||||
tariff_price_found = True
|
||||
|
||||
# Если не нашли в тарифе - используем PERIOD_PRICES
|
||||
if base_price_original <= 0:
|
||||
base_price_original = PERIOD_PRICES.get(duration_days, 0)
|
||||
|
||||
if tariff_price_found:
|
||||
# Тарифный режим: серверы и трафик включены в цену тарифа.
|
||||
# Порядок: база + устройства → скидка на полную сумму (как в calculate_renewal_price).
|
||||
from app.utils.promo_offer import get_user_active_promo_discount_percent
|
||||
|
||||
original_price = base_price_original
|
||||
|
||||
tariff_device_limit = tariff.device_limit if tariff.device_limit is not None else 0
|
||||
device_limit = (
|
||||
subscription.device_limit if subscription.device_limit is not None else tariff_device_limit
|
||||
)
|
||||
extra_devices = max(0, device_limit - tariff_device_limit)
|
||||
device_price_per_unit = (
|
||||
tariff.device_price_kopeks
|
||||
if tariff and tariff.device_price_kopeks is not None
|
||||
else settings.PRICE_PER_DEVICE
|
||||
)
|
||||
months_in_period = calculate_months_from_days(duration_days)
|
||||
devices_price = extra_devices * device_price_per_unit * months_in_period
|
||||
original_price += devices_price
|
||||
|
||||
# Скидка промогруппы на полную сумму (база + устройства)
|
||||
period_discount_percent = db_user.get_promo_discount('period', duration_days)
|
||||
discount_total = original_price * period_discount_percent // 100
|
||||
total_price = original_price - discount_total
|
||||
|
||||
# Promo-offer скидка (временная)
|
||||
promo_offer_percent = get_user_active_promo_discount_percent(db_user)
|
||||
if promo_offer_percent > 0:
|
||||
promo_offer_discount = total_price * promo_offer_percent // 100
|
||||
total_price = total_price - promo_offer_discount
|
||||
else:
|
||||
# Классический режим: серверы + трафик + устройства считаются отдельно
|
||||
period_discount_percent = db_user.get_promo_discount('period', duration_days)
|
||||
base_price, base_discount_total = apply_percentage_discount(
|
||||
base_price_original,
|
||||
period_discount_percent,
|
||||
)
|
||||
from app.services.subscription_service import SubscriptionService
|
||||
|
||||
subscription_service = SubscriptionService()
|
||||
(
|
||||
servers_price_per_month,
|
||||
per_server_monthly_prices,
|
||||
) = await subscription_service.get_countries_price_by_uuids(
|
||||
current_connected_squads,
|
||||
db,
|
||||
promo_group_id=db_user.promo_group_id,
|
||||
)
|
||||
servers_discount_percent = db_user.get_promo_discount('servers', duration_days)
|
||||
total_servers_price = 0
|
||||
for server_price in per_server_monthly_prices:
|
||||
discounted_per_month, discount_per_month = apply_percentage_discount(
|
||||
server_price,
|
||||
servers_discount_percent,
|
||||
)
|
||||
total_servers_price += discounted_per_month
|
||||
|
||||
traffic_price_per_month = settings.get_traffic_price(current_traffic)
|
||||
traffic_discount_percent = db_user.get_promo_discount('traffic', duration_days)
|
||||
traffic_discounted_per_month, traffic_discount_per_month = apply_percentage_discount(
|
||||
traffic_price_per_month,
|
||||
traffic_discount_percent,
|
||||
)
|
||||
|
||||
additional_devices = max(0, (current_device_limit or 0) - settings.DEFAULT_DEVICE_LIMIT)
|
||||
devices_price_per_month = additional_devices * settings.PRICE_PER_DEVICE
|
||||
devices_discount_percent = db_user.get_promo_discount('devices', duration_days)
|
||||
devices_discounted_per_month, devices_discount_per_month = apply_percentage_discount(
|
||||
devices_price_per_month,
|
||||
devices_discount_percent,
|
||||
)
|
||||
|
||||
months_in_period = calculate_months_from_days(duration_days)
|
||||
total_price = (
|
||||
base_price
|
||||
+ total_servers_price * months_in_period
|
||||
+ traffic_discounted_per_month * months_in_period
|
||||
+ devices_discounted_per_month * months_in_period
|
||||
)
|
||||
|
||||
traffic_value = current_traffic or 0
|
||||
if traffic_value <= 0:
|
||||
traffic_display = texts.t('TRAFFIC_UNLIMITED_SHORT', 'Безлимит')
|
||||
else:
|
||||
traffic_display = texts.format_traffic(traffic_value)
|
||||
|
||||
current_tariff_desc = (
|
||||
f'📱 Подписка: {len(current_connected_squads)} серверов, '
|
||||
f'{traffic_display}, {current_device_limit} устр.'
|
||||
)
|
||||
estimated_price_info = (
|
||||
f'💰 Стоимость продления (примерно): {texts.format_price(total_price)} за {duration_days} дней'
|
||||
)
|
||||
|
||||
tariff_info = f'\n\n📋 <b>Ваш текущий тариф:</b>\n{current_tariff_desc}\n{estimated_price_info}'
|
||||
except Exception as e:
|
||||
logger.warning(
|
||||
'Не удалось рассчитать стоимость текущей подписки для пользователя', db_user_id=db_user.id, error=e
|
||||
)
|
||||
tariff_info = ''
|
||||
|
||||
full_text = payment_text + tariff_info
|
||||
full_text = payment_text
|
||||
|
||||
keyboard = get_payment_methods_keyboard(0, db_user.language)
|
||||
|
||||
@@ -980,6 +852,11 @@ def register_balance_handlers(dp: Dispatcher):
|
||||
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|'))
|
||||
|
||||
from .riopay import process_riopay_quick_amount, 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 .mulenpay import check_mulenpay_payment_status
|
||||
|
||||
dp.callback_query.register(check_mulenpay_payment_status, F.data.startswith('check_mulenpay_'))
|
||||
|
||||
@@ -0,0 +1,354 @@
|
||||
"""Handler for RioPay balance top-up."""
|
||||
|
||||
import structlog
|
||||
from aiogram import types
|
||||
from aiogram.fsm.context import FSMContext
|
||||
from aiogram.types import InlineKeyboardButton, InlineKeyboardMarkup
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.config import settings
|
||||
from app.database.models import User
|
||||
from app.keyboards.inline import get_back_keyboard
|
||||
from app.localization.texts import get_texts
|
||||
from app.services.payment_service import PaymentService
|
||||
from app.states import BalanceStates
|
||||
from app.utils.decorators import error_handler
|
||||
|
||||
|
||||
logger = structlog.get_logger(__name__)
|
||||
|
||||
|
||||
def _check_topup_restriction(db_user: User, texts) -> InlineKeyboardMarkup | None:
|
||||
"""Проверяет ограничение на пополнение. Возвращает клавиатуру если ограничен, иначе None."""
|
||||
if not getattr(db_user, 'restriction_topup', False):
|
||||
return None
|
||||
|
||||
keyboard = []
|
||||
support_url = settings.get_support_contact_url()
|
||||
if support_url:
|
||||
keyboard.append([InlineKeyboardButton(text='🆘 Обжаловать', url=support_url)])
|
||||
keyboard.append([InlineKeyboardButton(text=texts.BACK, callback_data='menu_balance')])
|
||||
return InlineKeyboardMarkup(inline_keyboard=keyboard)
|
||||
|
||||
|
||||
async def _create_riopay_payment_and_respond(
|
||||
message_or_callback,
|
||||
db_user: User,
|
||||
db: AsyncSession,
|
||||
amount_kopeks: int,
|
||||
edit_message: bool = False,
|
||||
):
|
||||
"""
|
||||
Common logic for creating RioPay payment and sending response.
|
||||
"""
|
||||
texts = get_texts(db_user.language)
|
||||
amount_rub = amount_kopeks / 100
|
||||
|
||||
# Create payment
|
||||
payment_service = PaymentService()
|
||||
|
||||
description = settings.PAYMENT_BALANCE_TEMPLATE.format(
|
||||
service_name=settings.PAYMENT_SERVICE_NAME,
|
||||
description='Пополнение баланса',
|
||||
)
|
||||
|
||||
result = await payment_service.create_riopay_payment(
|
||||
db=db,
|
||||
user_id=db_user.id,
|
||||
amount_kopeks=amount_kopeks,
|
||||
description=description,
|
||||
email=getattr(db_user, 'email', None),
|
||||
language=db_user.language,
|
||||
)
|
||||
|
||||
if not result:
|
||||
error_text = texts.t(
|
||||
'PAYMENT_CREATE_ERROR',
|
||||
'Не удалось создать платёж. Попробуйте позже.',
|
||||
)
|
||||
if edit_message:
|
||||
await message_or_callback.edit_text(
|
||||
error_text,
|
||||
reply_markup=get_back_keyboard(db_user.language),
|
||||
parse_mode='HTML',
|
||||
)
|
||||
else:
|
||||
await message_or_callback.answer(
|
||||
error_text,
|
||||
parse_mode='HTML',
|
||||
)
|
||||
return
|
||||
|
||||
payment_url = result.get('payment_url')
|
||||
display_name = settings.get_riopay_display_name()
|
||||
|
||||
# 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',
|
||||
)
|
||||
],
|
||||
]
|
||||
)
|
||||
|
||||
response_text = texts.t(
|
||||
'RIOPAY_PAYMENT_CREATED',
|
||||
'💳 <b>Оплата через {name}</b>\n\n'
|
||||
'Сумма: <b>{amount}₽</b>\n\n'
|
||||
'Нажмите кнопку ниже для оплаты.\n'
|
||||
'После успешной оплаты баланс будет пополнен автоматически.',
|
||||
).format(name=display_name, amount=f'{amount_rub:.2f}')
|
||||
|
||||
if edit_message:
|
||||
await message_or_callback.edit_text(
|
||||
response_text,
|
||||
reply_markup=keyboard,
|
||||
parse_mode='HTML',
|
||||
)
|
||||
else:
|
||||
await message_or_callback.answer(
|
||||
response_text,
|
||||
reply_markup=keyboard,
|
||||
parse_mode='HTML',
|
||||
)
|
||||
|
||||
logger.info('RioPay payment created', telegram_id=db_user.telegram_id, amount_rub=amount_rub)
|
||||
|
||||
|
||||
@error_handler
|
||||
async def process_riopay_payment_amount(
|
||||
message: types.Message,
|
||||
db_user: User,
|
||||
db: AsyncSession,
|
||||
amount_kopeks: int,
|
||||
state: FSMContext,
|
||||
):
|
||||
"""
|
||||
Process payment amount directly (called from quick_amount handlers).
|
||||
"""
|
||||
texts = get_texts(db_user.language)
|
||||
|
||||
restriction_kb = _check_topup_restriction(db_user, texts)
|
||||
if restriction_kb:
|
||||
reason = getattr(db_user, 'restriction_reason', None) or 'Действие ограничено администратором'
|
||||
await message.answer(
|
||||
f'🚫 <b>Пополнение ограничено</b>\n\n{reason}',
|
||||
parse_mode='HTML',
|
||||
reply_markup=restriction_kb,
|
||||
)
|
||||
await state.clear()
|
||||
return
|
||||
|
||||
# Validate amount
|
||||
min_amount = settings.RIOPAY_MIN_AMOUNT_KOPEKS
|
||||
max_amount = settings.RIOPAY_MAX_AMOUNT_KOPEKS
|
||||
|
||||
if amount_kopeks < min_amount:
|
||||
await message.answer(
|
||||
texts.t(
|
||||
'PAYMENT_AMOUNT_TOO_LOW',
|
||||
'Минимальная сумма пополнения: {min_amount}₽',
|
||||
).format(min_amount=min_amount // 100),
|
||||
parse_mode='HTML',
|
||||
)
|
||||
return
|
||||
|
||||
if amount_kopeks > max_amount:
|
||||
await message.answer(
|
||||
texts.t(
|
||||
'PAYMENT_AMOUNT_TOO_HIGH',
|
||||
'Максимальная сумма пополнения: {max_amount}₽',
|
||||
).format(max_amount=max_amount // 100),
|
||||
parse_mode='HTML',
|
||||
)
|
||||
return
|
||||
|
||||
await state.clear()
|
||||
|
||||
await _create_riopay_payment_and_respond(
|
||||
message_or_callback=message,
|
||||
db_user=db_user,
|
||||
db=db,
|
||||
amount_kopeks=amount_kopeks,
|
||||
edit_message=False,
|
||||
)
|
||||
|
||||
|
||||
@error_handler
|
||||
async def start_riopay_topup(
|
||||
callback: types.CallbackQuery,
|
||||
db_user: User,
|
||||
db: AsyncSession,
|
||||
state: FSMContext,
|
||||
):
|
||||
"""
|
||||
Start RioPay top-up process - ask for amount.
|
||||
"""
|
||||
texts = get_texts(db_user.language)
|
||||
|
||||
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
|
||||
|
||||
await state.set_state(BalanceStates.waiting_for_amount)
|
||||
await state.update_data(payment_method='riopay')
|
||||
|
||||
min_amount = settings.RIOPAY_MIN_AMOUNT_KOPEKS // 100
|
||||
max_amount = settings.RIOPAY_MAX_AMOUNT_KOPEKS // 100
|
||||
display_name = settings.get_riopay_display_name()
|
||||
|
||||
keyboard = InlineKeyboardMarkup(
|
||||
inline_keyboard=[
|
||||
[
|
||||
InlineKeyboardButton(
|
||||
text=texts.t('BACK_BUTTON', '◀️ Назад'),
|
||||
callback_data='menu_balance',
|
||||
)
|
||||
]
|
||||
]
|
||||
)
|
||||
|
||||
await callback.message.edit_text(
|
||||
texts.t(
|
||||
'RIOPAY_ENTER_AMOUNT',
|
||||
'💳 <b>Пополнение через {name}</b>\n\n'
|
||||
'Введите сумму пополнения в рублях.\n\n'
|
||||
'Минимум: {min_amount}₽\n'
|
||||
'Максимум: {max_amount}₽',
|
||||
).format(
|
||||
name=display_name,
|
||||
min_amount=min_amount,
|
||||
max_amount=f'{max_amount:,}'.replace(',', ' '),
|
||||
),
|
||||
parse_mode='HTML',
|
||||
reply_markup=keyboard,
|
||||
)
|
||||
|
||||
|
||||
@error_handler
|
||||
async def process_riopay_custom_amount(
|
||||
message: types.Message,
|
||||
db_user: User,
|
||||
db: AsyncSession,
|
||||
state: FSMContext,
|
||||
):
|
||||
"""
|
||||
Process custom amount input for RioPay payment.
|
||||
"""
|
||||
data = await state.get_data()
|
||||
if data.get('payment_method') != 'riopay':
|
||||
return
|
||||
|
||||
texts = get_texts(db_user.language)
|
||||
|
||||
try:
|
||||
amount_text = message.text.replace(',', '.').replace(' ', '').strip()
|
||||
amount_rubles = float(amount_text)
|
||||
amount_kopeks = round(amount_rubles * 100)
|
||||
except (ValueError, TypeError):
|
||||
await message.answer(
|
||||
texts.t(
|
||||
'PAYMENT_INVALID_AMOUNT',
|
||||
'Введите корректную сумму числом.',
|
||||
),
|
||||
parse_mode='HTML',
|
||||
)
|
||||
return
|
||||
|
||||
await process_riopay_payment_amount(
|
||||
message=message,
|
||||
db_user=db_user,
|
||||
db=db,
|
||||
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,
|
||||
)
|
||||
+50
-18
@@ -1,3 +1,4 @@
|
||||
import hashlib
|
||||
import json
|
||||
from pathlib import Path
|
||||
|
||||
@@ -37,10 +38,14 @@ async def show_referral_info(callback: types.CallbackQuery, db_user: User, db: A
|
||||
|
||||
texts = get_texts(db_user.language)
|
||||
|
||||
if not db_user.referral_code:
|
||||
await callback.answer(texts.t('REFERRAL_CODE_NOT_ASSIGNED', 'Реферальный код не назначен'), show_alert=True)
|
||||
return
|
||||
|
||||
summary = await get_user_referral_summary(db, db_user.id)
|
||||
|
||||
bot_username = (await callback.bot.get_me()).username
|
||||
referral_link = f'https://t.me/{bot_username}?start={db_user.referral_code}'
|
||||
referral_link = settings.get_referral_link(db_user.referral_code, bot_username)
|
||||
|
||||
referral_text = (
|
||||
texts.t('REFERRAL_PROGRAM_TITLE', '👥 <b>Реферальная программа</b>')
|
||||
@@ -78,24 +83,40 @@ async def show_referral_info(callback: types.CallbackQuery, db_user: User, db: A
|
||||
).format(amount=texts.format_price(summary['month_earned_kopeks']))
|
||||
+ '\n\n'
|
||||
+ texts.t('REFERRAL_REWARDS_HEADER', '🎁 <b>Как работают награды:</b>')
|
||||
+ '\n'
|
||||
+ texts.t(
|
||||
)
|
||||
|
||||
if settings.REFERRAL_FIRST_TOPUP_BONUS_KOPEKS > 0:
|
||||
referral_text += '\n' + texts.t(
|
||||
'REFERRAL_REWARD_NEW_USER',
|
||||
'• Новый пользователь получает: <b>{bonus}</b> при первом пополнении от <b>{minimum}</b>',
|
||||
).format(
|
||||
bonus=texts.format_price(settings.REFERRAL_FIRST_TOPUP_BONUS_KOPEKS),
|
||||
minimum=texts.format_price(settings.REFERRAL_MINIMUM_TOPUP_KOPEKS),
|
||||
)
|
||||
+ '\n'
|
||||
+ texts.t(
|
||||
|
||||
if settings.REFERRAL_INVITER_BONUS_KOPEKS > 0:
|
||||
referral_text += '\n' + texts.t(
|
||||
'REFERRAL_REWARD_INVITER',
|
||||
'• Вы получаете при первом пополнении реферала: <b>{bonus}</b>',
|
||||
).format(bonus=texts.format_price(settings.REFERRAL_INVITER_BONUS_KOPEKS))
|
||||
+ '\n'
|
||||
+ texts.t(
|
||||
|
||||
if settings.REFERRAL_MAX_COMMISSION_PAYMENTS > 0:
|
||||
commission_line = texts.t(
|
||||
'REFERRAL_REWARD_COMMISSION_LIMITED',
|
||||
'• Комиссия с первых {max_payments} пополнений реферала: <b>{percent}%</b>',
|
||||
).format(
|
||||
percent=get_effective_referral_commission_percent(db_user),
|
||||
max_payments=settings.REFERRAL_MAX_COMMISSION_PAYMENTS,
|
||||
)
|
||||
else:
|
||||
commission_line = texts.t(
|
||||
'REFERRAL_REWARD_COMMISSION',
|
||||
'• Комиссия с каждого пополнения реферала: <b>{percent}%</b>',
|
||||
).format(percent=get_effective_referral_commission_percent(db_user))
|
||||
|
||||
referral_text += (
|
||||
'\n'
|
||||
+ commission_line
|
||||
+ '\n\n'
|
||||
+ texts.t('REFERRAL_LINK_TITLE', '🔗 <b>Ваша реферальная ссылка:</b>')
|
||||
+ f'\n<code>{referral_link}</code>\n\n'
|
||||
@@ -213,17 +234,22 @@ async def show_referral_qr(
|
||||
callback: types.CallbackQuery,
|
||||
db_user: User,
|
||||
):
|
||||
await callback.answer()
|
||||
|
||||
texts = get_texts(db_user.language)
|
||||
|
||||
if not db_user.referral_code:
|
||||
await callback.answer(texts.t('REFERRAL_CODE_NOT_ASSIGNED', 'Реферальный код не назначен'), show_alert=True)
|
||||
return
|
||||
|
||||
await callback.answer()
|
||||
|
||||
bot_username = (await callback.bot.get_me()).username
|
||||
referral_link = f'https://t.me/{bot_username}?start={db_user.referral_code}'
|
||||
referral_link = settings.get_referral_link(db_user.referral_code, bot_username)
|
||||
|
||||
qr_dir = Path('data') / 'referral_qr'
|
||||
qr_dir.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
file_path = qr_dir / f'{db_user.id}.png'
|
||||
link_hash = hashlib.md5(referral_link.encode()).hexdigest()[:8]
|
||||
file_path = qr_dir / f'{db_user.id}_{link_hash}.png'
|
||||
if not file_path.exists():
|
||||
img = qrcode.make(referral_link)
|
||||
img.save(file_path)
|
||||
@@ -454,20 +480,26 @@ async def show_referral_analytics(callback: types.CallbackQuery, db_user: User,
|
||||
async def create_invite_message(callback: types.CallbackQuery, db_user: User):
|
||||
texts = get_texts(db_user.language)
|
||||
|
||||
bot_username = (await callback.bot.get_me()).username
|
||||
referral_link = f'https://t.me/{bot_username}?start={db_user.referral_code}'
|
||||
if not db_user.referral_code:
|
||||
await callback.answer(texts.t('REFERRAL_CODE_NOT_ASSIGNED', 'Реферальный код не назначен'), show_alert=True)
|
||||
return
|
||||
|
||||
invite_text = (
|
||||
texts.t('REFERRAL_INVITE_TITLE', '🎉 Присоединяйся к VPN сервису!')
|
||||
+ '\n\n'
|
||||
+ texts.t(
|
||||
bot_username = (await callback.bot.get_me()).username
|
||||
referral_link = settings.get_referral_link(db_user.referral_code, bot_username)
|
||||
|
||||
invite_text = texts.t('REFERRAL_INVITE_TITLE', '🎉 Присоединяйся к VPN сервису!')
|
||||
|
||||
if settings.REFERRAL_FIRST_TOPUP_BONUS_KOPEKS > 0:
|
||||
invite_text += '\n\n' + texts.t(
|
||||
'REFERRAL_INVITE_BONUS',
|
||||
'💎 При первом пополнении от {minimum} ты получишь {bonus} бонусом на баланс!',
|
||||
).format(
|
||||
minimum=texts.format_price(settings.REFERRAL_MINIMUM_TOPUP_KOPEKS),
|
||||
bonus=texts.format_price(settings.REFERRAL_FIRST_TOPUP_BONUS_KOPEKS),
|
||||
)
|
||||
+ '\n\n'
|
||||
|
||||
invite_text += (
|
||||
'\n\n'
|
||||
+ texts.t('REFERRAL_INVITE_FEATURE_FAST', '🚀 Быстрое подключение')
|
||||
+ '\n'
|
||||
+ texts.t('REFERRAL_INVITE_FEATURE_SERVERS', '🌍 Серверы по всему миру')
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import html
|
||||
from datetime import UTC, datetime
|
||||
|
||||
import structlog
|
||||
@@ -169,7 +170,7 @@ def _split_into_pages(
|
||||
|
||||
pages.append((current_online, current_offline))
|
||||
|
||||
return pages if pages else [([], [])]
|
||||
return pages or [([], [])]
|
||||
|
||||
|
||||
def _format_server_lines(
|
||||
@@ -189,7 +190,7 @@ def _format_server_lines(
|
||||
else:
|
||||
latency_text = texts.t('SERVER_STATUS_OFFLINE', 'нет ответа')
|
||||
|
||||
name = server.display_name or server.name
|
||||
name = html.escape(server.display_name or server.name)
|
||||
flag_prefix = f'{server.flag} ' if server.flag else ''
|
||||
server_line = f'{flag_prefix}{name} — {latency_text}'
|
||||
lines.append(f'<blockquote>{server_line}</blockquote>')
|
||||
|
||||
@@ -649,6 +649,7 @@ async def handle_simple_subscription_pay_with_balance(
|
||||
subscription_params['period_days'],
|
||||
False, # was_trial_conversion
|
||||
amount_kopeks=price_kopeks,
|
||||
purchase_type='renewal' if existing_subscription else 'first_purchase',
|
||||
)
|
||||
except Exception as e:
|
||||
logger.error('Ошибка отправки уведомления админам о покупке', error=e)
|
||||
@@ -970,7 +971,7 @@ async def handle_simple_subscription_payment_method(
|
||||
from aiogram.types import BufferedInputFile
|
||||
|
||||
# Используем qr_confirmation_data если доступно, иначе confirmation_url
|
||||
qr_data = qr_confirmation_data if qr_confirmation_data else confirmation_url
|
||||
qr_data = qr_confirmation_data or confirmation_url
|
||||
|
||||
# Создаем QR-код из полученных данных
|
||||
qr = qrcode.QRCode(version=1, box_size=10, border=5)
|
||||
@@ -2368,6 +2369,7 @@ async def confirm_simple_subscription_purchase(
|
||||
subscription_params['period_days'],
|
||||
False, # was_trial_conversion
|
||||
amount_kopeks=price_kopeks,
|
||||
purchase_type='renewal' if existing_subscription else 'first_purchase',
|
||||
)
|
||||
except Exception as e:
|
||||
logger.error('Ошибка отправки уведомления админам о покупке', error=e)
|
||||
|
||||
@@ -285,6 +285,90 @@ async def _handle_trial_payment(
|
||||
return False
|
||||
|
||||
|
||||
_PURCHASE_TOKEN_RE = __import__('re').compile(r'^[A-Za-z0-9_\-]{10,100}$')
|
||||
|
||||
|
||||
async def _handle_guest_purchase_payment(
|
||||
message: types.Message,
|
||||
db: AsyncSession,
|
||||
user,
|
||||
stars_amount: int,
|
||||
payload: str,
|
||||
telegram_payment_charge_id: str,
|
||||
):
|
||||
"""Обработка Stars платежа для гостевой покупки (подарочная подписка из кабинета)."""
|
||||
from app.database.crud.landing import get_purchase_by_token
|
||||
from app.services.payment.common import try_fulfill_guest_purchase
|
||||
|
||||
try:
|
||||
purchase_token = payload[len('guest_purchase_') :]
|
||||
if not purchase_token or not _PURCHASE_TOKEN_RE.match(purchase_token):
|
||||
logger.error('Invalid purchase_token format in guest_purchase payload', payload=payload)
|
||||
await message.answer('❌ Ошибка: неверный формат платежа.')
|
||||
return
|
||||
|
||||
# Verify Stars amount matches expected price (±5% tolerance for conversion rounding)
|
||||
existing = await get_purchase_by_token(db, purchase_token)
|
||||
if existing and existing.amount_kopeks:
|
||||
expected_stars = max(1, settings.rubles_to_stars(existing.amount_kopeks / 100))
|
||||
tolerance = max(1, round(expected_stars * 0.05))
|
||||
if abs(stars_amount - expected_stars) > tolerance:
|
||||
logger.error(
|
||||
'Stars amount mismatch for guest purchase',
|
||||
paid_stars=stars_amount,
|
||||
expected_stars=expected_stars,
|
||||
purchase_token_prefix=purchase_token[:5],
|
||||
)
|
||||
await message.answer('❌ Сумма оплаты не совпадает с ожидаемой.')
|
||||
return
|
||||
|
||||
# Calculate kopeks from stars
|
||||
rubles_amount = TelegramStarsService.calculate_rubles_from_stars(stars_amount)
|
||||
amount_kopeks = int((rubles_amount * Decimal(100)).to_integral_value(rounding=ROUND_HALF_UP))
|
||||
|
||||
# Build metadata matching what other providers use
|
||||
metadata = {
|
||||
'purpose': 'guest_purchase',
|
||||
'purchase_token': purchase_token,
|
||||
}
|
||||
|
||||
result = await try_fulfill_guest_purchase(
|
||||
db,
|
||||
metadata=metadata,
|
||||
payment_amount_kopeks=amount_kopeks,
|
||||
provider_payment_id=telegram_payment_charge_id,
|
||||
provider_name='telegram_stars',
|
||||
skip_amount_check=True,
|
||||
)
|
||||
|
||||
if result is True:
|
||||
await message.answer(
|
||||
'🎁 <b>Подарочная подписка успешно оплачена!</b>\n\n'
|
||||
f'⭐ Потрачено: {stars_amount} Stars\n\n'
|
||||
'Подарок будет доставлен получателю.',
|
||||
parse_mode='HTML',
|
||||
)
|
||||
logger.info(
|
||||
'✅ Guest purchase fulfilled via Stars',
|
||||
user_id=user.id,
|
||||
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('❌ Ошибка обработки платежа. Обратитесь в поддержку.')
|
||||
|
||||
except Exception as e:
|
||||
logger.error('Error handling guest purchase Stars payment', error=e, exc_info=True)
|
||||
await message.answer(
|
||||
'❌ Произошла ошибка при обработке подарочной подписки. Обратитесь в поддержку.',
|
||||
)
|
||||
|
||||
|
||||
async def handle_pre_checkout_query(query: types.PreCheckoutQuery):
|
||||
texts = get_texts(DEFAULT_LANGUAGE)
|
||||
|
||||
@@ -296,7 +380,7 @@ async def handle_pre_checkout_query(query: types.PreCheckoutQuery):
|
||||
invoice_payload=query.invoice_payload,
|
||||
)
|
||||
|
||||
allowed_prefixes = ('balance_', 'admin_stars_test_', 'simple_sub_', 'wheel_spin_', 'trial_')
|
||||
allowed_prefixes = ('balance_', 'admin_stars_test_', 'simple_sub_', 'wheel_spin_', 'trial_', 'guest_purchase_')
|
||||
|
||||
if not query.invoice_payload or not query.invoice_payload.startswith(allowed_prefixes):
|
||||
logger.warning('Невалидный payload', invoice_payload=query.invoice_payload)
|
||||
@@ -402,6 +486,18 @@ async def handle_successful_payment(message: types.Message, db: AsyncSession, st
|
||||
)
|
||||
return
|
||||
|
||||
# Обработка оплаты гостевой покупки (подарочная подписка из кабинета)
|
||||
if payment.invoice_payload and payment.invoice_payload.startswith('guest_purchase_'):
|
||||
await _handle_guest_purchase_payment(
|
||||
message=message,
|
||||
db=db,
|
||||
user=user,
|
||||
stars_amount=payment.total_amount,
|
||||
payload=payment.invoice_payload,
|
||||
telegram_payment_charge_id=payment.telegram_payment_charge_id,
|
||||
)
|
||||
return
|
||||
|
||||
payment_service = PaymentService(message.bot)
|
||||
|
||||
state_data = await state.get_data()
|
||||
|
||||
+106
-11
@@ -1,7 +1,10 @@
|
||||
from collections.abc import Callable
|
||||
from datetime import UTC, datetime
|
||||
from typing import Any
|
||||
|
||||
import structlog
|
||||
from aiogram import Bot, Dispatcher, F, types
|
||||
from aiogram.enums import ParseMode
|
||||
from aiogram.exceptions import TelegramBadRequest, TelegramForbiddenError
|
||||
from aiogram.filters import Command, StateFilter
|
||||
from aiogram.fsm.context import FSMContext
|
||||
@@ -21,7 +24,7 @@ from app.database.crud.user import (
|
||||
get_user_by_telegram_id,
|
||||
)
|
||||
from app.database.crud.user_message import get_random_active_message
|
||||
from app.database.models import PinnedMessage, SubscriptionStatus, UserStatus
|
||||
from app.database.models import GuestPurchase, GuestPurchaseStatus, PinnedMessage, SubscriptionStatus, UserStatus
|
||||
from app.keyboards.inline import (
|
||||
get_back_keyboard,
|
||||
get_language_selection_keyboard,
|
||||
@@ -60,6 +63,73 @@ from app.utils.user_utils import generate_unique_referral_code
|
||||
logger = structlog.get_logger(__name__)
|
||||
|
||||
|
||||
async def _activate_pending_gift_after_registration(
|
||||
db: AsyncSession,
|
||||
state: FSMContext,
|
||||
user: 'User',
|
||||
answer_func: Callable[..., Any],
|
||||
) -> None:
|
||||
"""Extract pending_gift_token from FSM state and activate it for the newly registered user.
|
||||
|
||||
Must be called BEFORE state.clear() to preserve the token.
|
||||
"""
|
||||
gift_token: str | None = None
|
||||
try:
|
||||
fresh_state = await state.get_data()
|
||||
gift_token = fresh_state.get('pending_gift_token')
|
||||
if not gift_token:
|
||||
return
|
||||
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.orm import selectinload
|
||||
|
||||
from app.services.guest_purchase_service import activate_purchase as svc_activate
|
||||
|
||||
# Support both full token and prefix-based lookup (Telegram truncates long start params)
|
||||
if len(gift_token) >= 64:
|
||||
token_filter = GuestPurchase.token == gift_token
|
||||
else:
|
||||
token_filter = GuestPurchase.token.startswith(gift_token)
|
||||
|
||||
gift_result = await db.execute(
|
||||
select(GuestPurchase)
|
||||
.options(selectinload(GuestPurchase.tariff))
|
||||
.where(token_filter, GuestPurchase.is_gift.is_(True))
|
||||
.with_for_update()
|
||||
)
|
||||
gift_purchase = gift_result.scalars().first()
|
||||
if (
|
||||
gift_purchase
|
||||
and gift_purchase.is_gift
|
||||
and gift_purchase.status
|
||||
in (
|
||||
GuestPurchaseStatus.PENDING_ACTIVATION.value,
|
||||
GuestPurchaseStatus.PAID.value,
|
||||
)
|
||||
and (gift_purchase.user_id is None or gift_purchase.user_id == user.id)
|
||||
and gift_purchase.buyer_user_id != user.id # prevent self-activation
|
||||
):
|
||||
if gift_purchase.user_id is None:
|
||||
gift_purchase.user_id = user.id
|
||||
# Transition PAID → PENDING_ACTIVATION so activate_purchase() accepts it
|
||||
if gift_purchase.status == GuestPurchaseStatus.PAID.value:
|
||||
gift_purchase.status = GuestPurchaseStatus.PENDING_ACTIVATION.value
|
||||
await db.flush()
|
||||
await svc_activate(db, gift_purchase.token, skip_notification=True)
|
||||
tariff_name = gift_purchase.tariff.name if gift_purchase.tariff else ''
|
||||
await answer_func(
|
||||
f'🎁 <b>Подарок активирован!</b>\n'
|
||||
f'{tariff_name} — {gift_purchase.period_days} дн.\n\n'
|
||||
f'Ваша подписка обновлена.',
|
||||
parse_mode=ParseMode.HTML,
|
||||
)
|
||||
except Exception:
|
||||
logger.exception(
|
||||
'Failed to auto-activate gift after registration',
|
||||
token_prefix=(gift_token or '')[:5],
|
||||
)
|
||||
|
||||
|
||||
async def _claim_phantom_user(
|
||||
db: AsyncSession,
|
||||
phantom: 'User',
|
||||
@@ -446,6 +516,20 @@ async def cmd_start(message: types.Message, state: FSMContext, db: AsyncSession,
|
||||
if state_needs_update:
|
||||
await state.set_data(data)
|
||||
|
||||
# Handle gift code deep links: /start GIFT_{token}
|
||||
if start_parameter and start_parameter.startswith('GIFT_'):
|
||||
gift_token = start_parameter[5:] # Strip "GIFT_" prefix
|
||||
if len(gift_token) >= 8:
|
||||
logger.info(
|
||||
'Gift code deep link detected',
|
||||
token_prefix=gift_token[:5],
|
||||
telegram_id=message.from_user.id,
|
||||
)
|
||||
# For new users, gift is auto-activated via
|
||||
# _activate_pending_gift_after_registration() before state.clear().
|
||||
await state.update_data(pending_gift_token=gift_token)
|
||||
start_parameter = None # Don't treat as campaign or referral
|
||||
|
||||
if start_parameter:
|
||||
campaign = await get_campaign_by_start_parameter(
|
||||
db,
|
||||
@@ -481,7 +565,7 @@ async def cmd_start(message: types.Message, state: FSMContext, db: AsyncSession,
|
||||
if referral_code:
|
||||
await state.update_data(referral_code=referral_code)
|
||||
|
||||
user = db_user if db_user else await get_user_by_telegram_id(db, message.from_user.id)
|
||||
user = db_user or await get_user_by_telegram_id(db, message.from_user.id)
|
||||
|
||||
if campaign and not campaign_notification_sent:
|
||||
try:
|
||||
@@ -553,6 +637,13 @@ async def cmd_start(message: types.Message, state: FSMContext, db: AsyncSession,
|
||||
except Exception as e:
|
||||
logger.error('Ошибка отправки уведомления о рекламной кампании', error=e)
|
||||
|
||||
# Auto-activate pending gift if deep link contained GIFT_
|
||||
if user:
|
||||
await _activate_pending_gift_after_registration(db, state, user, message.answer)
|
||||
await state.update_data(pending_gift_token=None)
|
||||
# Refresh user to pick up newly created subscription
|
||||
await db.refresh(user, attribute_names=['subscription'])
|
||||
|
||||
has_active_subscription, subscription_is_active = _calculate_subscription_flags(user.subscription)
|
||||
|
||||
pinned_message = await get_active_pinned_message(db)
|
||||
@@ -1364,6 +1455,9 @@ async def complete_registration_from_callback(callback: types.CallbackQuery, sta
|
||||
telegram_id=user.telegram_id,
|
||||
)
|
||||
|
||||
# Auto-activate pending gift for newly registered user (before state.clear() wipes the token)
|
||||
await _activate_pending_gift_after_registration(db, state, user, callback.message.answer)
|
||||
|
||||
await state.clear()
|
||||
|
||||
if campaign_message:
|
||||
@@ -1682,6 +1776,9 @@ async def complete_registration(message: types.Message, state: FSMContext, db: A
|
||||
'🗑️ COMPLETE: Redis payload удален после успешной регистрации пользователя', telegram_id=user.telegram_id
|
||||
)
|
||||
|
||||
# Auto-activate pending gift for newly registered user (before state.clear() wipes the token)
|
||||
await _activate_pending_gift_after_registration(db, state, user, message.answer)
|
||||
|
||||
await state.clear()
|
||||
|
||||
if campaign_message:
|
||||
@@ -2021,14 +2118,6 @@ async def required_sub_channel_check(
|
||||
# Очищаем Redis после успешной проверки подписки
|
||||
await delete_pending_payload_from_redis(query.from_user.id)
|
||||
|
||||
# Всегда обновляем referral_code если есть новый payload
|
||||
# (исправление бага с устаревшими данными в state)
|
||||
campaign = await get_campaign_by_start_parameter(
|
||||
db,
|
||||
pending_start_payload,
|
||||
only_active=True,
|
||||
)
|
||||
|
||||
# Обрабатываем payload только если ещё не обработан
|
||||
# (проверяем по наличию referral_code или campaign_id в state)
|
||||
if not state_data.get('referral_code') and not state_data.get('campaign_id'):
|
||||
@@ -2040,7 +2129,13 @@ async def required_sub_channel_check(
|
||||
|
||||
if campaign:
|
||||
state_data['campaign_id'] = campaign.id
|
||||
logger.info('📣 CHANNEL CHECK: Кампания восстановлена из payload', campaign_id=campaign.id)
|
||||
if campaign.partner_user_id:
|
||||
state_data['referrer_id'] = campaign.partner_user_id
|
||||
logger.info(
|
||||
'📣 CHANNEL CHECK: Кампания восстановлена из payload',
|
||||
campaign_id=campaign.id,
|
||||
partner_user_id=campaign.partner_user_id,
|
||||
)
|
||||
else:
|
||||
state_data['referral_code'] = pending_start_payload
|
||||
logger.info(
|
||||
|
||||
@@ -3,7 +3,7 @@ import base64
|
||||
import html as html_mod
|
||||
import re
|
||||
import time
|
||||
from datetime import datetime
|
||||
from datetime import UTC, datetime
|
||||
from typing import Any
|
||||
from urllib.parse import quote
|
||||
|
||||
@@ -15,7 +15,6 @@ from app.database.models import Subscription, User
|
||||
from app.localization.texts import get_texts
|
||||
from app.utils.pricing_utils import (
|
||||
apply_percentage_discount,
|
||||
get_remaining_months,
|
||||
)
|
||||
from app.utils.promo_offer import (
|
||||
get_user_active_promo_discount_percent,
|
||||
@@ -109,14 +108,15 @@ def _apply_promo_offer_discount(user: User | None, amount: int) -> dict[str, int
|
||||
|
||||
|
||||
def _get_period_hint_from_subscription(subscription: Subscription | None) -> int | None:
|
||||
if not subscription:
|
||||
if not subscription or not subscription.end_date:
|
||||
return None
|
||||
|
||||
months_remaining = get_remaining_months(subscription.end_date)
|
||||
if months_remaining <= 0:
|
||||
now = datetime.now(UTC)
|
||||
days_remaining = (subscription.end_date - now).days
|
||||
if days_remaining <= 0:
|
||||
return None
|
||||
|
||||
return months_remaining * 30
|
||||
return days_remaining
|
||||
|
||||
|
||||
def _apply_discount_to_monthly_component(
|
||||
@@ -518,12 +518,15 @@ def get_traffic_switch_keyboard(
|
||||
if base_traffic_gb is None:
|
||||
base_traffic_gb = current_traffic_gb
|
||||
|
||||
months_multiplier = 1
|
||||
period_text = ''
|
||||
# Считаем по дням (как в кабинете и подтверждении)
|
||||
if subscription_end_date:
|
||||
months_multiplier = get_remaining_months(subscription_end_date)
|
||||
if months_multiplier > 1:
|
||||
period_text = f' (за {months_multiplier} мес)'
|
||||
now = datetime.now(UTC)
|
||||
days_left = max(1, (subscription_end_date - now).days)
|
||||
price_multiplier = days_left / 30
|
||||
period_text = f' (за {days_left} дн.)' if days_left > 1 else ' (за 1 день)'
|
||||
else:
|
||||
price_multiplier = 1
|
||||
period_text = ''
|
||||
|
||||
packages = settings.get_traffic_packages()
|
||||
enabled_packages = [pkg for pkg in packages if pkg['enabled']]
|
||||
@@ -546,7 +549,7 @@ def get_traffic_switch_keyboard(
|
||||
)
|
||||
|
||||
price_diff_per_month = discounted_price_per_month - discounted_current_per_month
|
||||
total_price_diff = price_diff_per_month * months_multiplier
|
||||
total_price_diff = int(price_diff_per_month * price_multiplier)
|
||||
|
||||
# Сравниваем с базовым трафиком (без докупленного)
|
||||
if gb == base_traffic_gb:
|
||||
@@ -558,7 +561,7 @@ def get_traffic_switch_keyboard(
|
||||
action_text = ''
|
||||
price_text = f' (+{total_price_diff // 100}₽{period_text})'
|
||||
if discount_percent > 0:
|
||||
discount_total = (price_per_month - current_price_per_month) * months_multiplier - total_price_diff
|
||||
discount_total = int((price_per_month - current_price_per_month) * price_multiplier) - total_price_diff
|
||||
if discount_total > 0:
|
||||
price_text += f' (скидка {discount_percent}%: -{discount_total // 100}₽)'
|
||||
elif total_price_diff < 0:
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import html
|
||||
from datetime import UTC, datetime
|
||||
|
||||
from aiogram import types
|
||||
@@ -25,7 +26,6 @@ from app.states import SubscriptionStates
|
||||
from app.utils.pricing_utils import (
|
||||
apply_percentage_discount,
|
||||
calculate_prorated_price,
|
||||
get_remaining_months,
|
||||
)
|
||||
|
||||
from .common import _get_addon_discount_percent_for_user, _get_period_hint_from_subscription, logger
|
||||
@@ -67,7 +67,7 @@ async def handle_add_countries(callback: types.CallbackQuery, db_user: User, db:
|
||||
current_countries_names = []
|
||||
for country in countries:
|
||||
if country['uuid'] in current_countries:
|
||||
current_countries_names.append(country['name'])
|
||||
current_countries_names.append(html.escape(country['name']))
|
||||
|
||||
current_list = (
|
||||
'\n'.join(f'• {name}' for name in current_countries_names)
|
||||
@@ -253,9 +253,10 @@ async def apply_countries_changes(callback: types.CallbackQuery, db_user: User,
|
||||
|
||||
logger.info('🔧 Добавлено: Удалено', added=added, removed=removed)
|
||||
|
||||
months_to_pay = get_remaining_months(subscription.end_date)
|
||||
now = datetime.now(UTC)
|
||||
days_to_pay = max(1, (subscription.end_date - now).days)
|
||||
|
||||
period_hint_days = months_to_pay * 30 if months_to_pay > 0 else None
|
||||
period_hint_days = days_to_pay if days_to_pay > 0 else None
|
||||
servers_discount_percent = _get_addon_discount_percent_for_user(
|
||||
db_user,
|
||||
'servers',
|
||||
@@ -290,24 +291,28 @@ async def apply_countries_changes(callback: types.CallbackQuery, db_user: User,
|
||||
if country['uuid'] in removed:
|
||||
removed_names.append(country['name'])
|
||||
|
||||
total_cost, charged_months = calculate_prorated_price(cost_per_month, subscription.end_date)
|
||||
total_cost, charged_days = calculate_prorated_price(cost_per_month, subscription.end_date)
|
||||
|
||||
added_server_prices = [component['discounted_per_month'] * charged_months for component in added_server_components]
|
||||
added_server_prices = [
|
||||
int(component['discounted_per_month'] * charged_days / 30) for component in added_server_components
|
||||
]
|
||||
|
||||
total_discount = sum(component['discount_per_month'] * charged_months for component in added_server_components)
|
||||
total_discount = sum(
|
||||
int(component['discount_per_month'] * charged_days / 30) for component in added_server_components
|
||||
)
|
||||
|
||||
if added_names:
|
||||
logger.info(
|
||||
'Стоимость новых серверов: ₽/мес × мес = ₽ (скидка ₽)',
|
||||
'Стоимость новых серверов: ₽/мес × дн./30 = ₽ (скидка ₽)',
|
||||
cost_per_month=cost_per_month / 100,
|
||||
charged_months=charged_months,
|
||||
charged_days=charged_days,
|
||||
total_cost=total_cost / 100,
|
||||
total_discount=total_discount / 100,
|
||||
)
|
||||
|
||||
if total_cost > 0 and db_user.balance_kopeks < total_cost:
|
||||
missing_kopeks = total_cost - db_user.balance_kopeks
|
||||
required_text = f'{texts.format_price(total_cost)} (за {charged_months} мес)'
|
||||
required_text = f'{texts.format_price(total_cost)} (за {charged_days} дн.)'
|
||||
message_text = texts.t(
|
||||
'ADDON_INSUFFICIENT_FUNDS_MESSAGE',
|
||||
(
|
||||
@@ -349,7 +354,7 @@ async def apply_countries_changes(callback: types.CallbackQuery, db_user: User,
|
||||
try:
|
||||
if added and total_cost > 0:
|
||||
success = await subtract_user_balance(
|
||||
db, db_user, total_cost, f'Добавление стран: {", ".join(added_names)} на {charged_months} мес'
|
||||
db, db_user, total_cost, f'Добавление стран: {", ".join(added_names)} за {charged_days} дн.'
|
||||
)
|
||||
if not success:
|
||||
await callback.answer(
|
||||
@@ -363,7 +368,7 @@ async def apply_countries_changes(callback: types.CallbackQuery, db_user: User,
|
||||
user_id=db_user.id,
|
||||
type=TransactionType.SUBSCRIPTION_PAYMENT,
|
||||
amount_kopeks=total_cost,
|
||||
description=f'Добавление стран к подписке: {", ".join(added_names)} на {charged_months} мес',
|
||||
description=f'Добавление стран к подписке: {", ".join(added_names)} за {charged_days} дн.',
|
||||
)
|
||||
|
||||
if added:
|
||||
@@ -377,8 +382,8 @@ async def apply_countries_changes(callback: types.CallbackQuery, db_user: User,
|
||||
await add_user_to_servers(db, added_server_ids)
|
||||
|
||||
logger.info(
|
||||
'📊 Добавлены серверы с ценами за мес',
|
||||
charged_months=charged_months,
|
||||
'📊 Добавлены серверы с ценами за дн.',
|
||||
charged_days=charged_days,
|
||||
value=list(zip(added_server_ids, added_server_prices, strict=False)),
|
||||
)
|
||||
|
||||
@@ -415,10 +420,10 @@ async def apply_countries_changes(callback: types.CallbackQuery, db_user: User,
|
||||
if total_cost > 0:
|
||||
success_text += '\n' + texts.t(
|
||||
'COUNTRY_CHANGES_CHARGED',
|
||||
'💰 Списано: {amount} (за {months} мес)',
|
||||
'💰 Списано: {amount} (за {days} дн.)',
|
||||
).format(
|
||||
amount=texts.format_price(total_cost),
|
||||
months=charged_months,
|
||||
days=charged_days,
|
||||
)
|
||||
if total_discount > 0:
|
||||
success_text += texts.t(
|
||||
@@ -655,8 +660,8 @@ def _build_countries_selection_text(countries: list[dict], base_text: str) -> st
|
||||
continue
|
||||
desc = country.get('description', '').strip()
|
||||
if desc:
|
||||
name = country.get('name', '')
|
||||
descriptions.append(f'<b>{name}</b>\n{desc}')
|
||||
name = html.escape(country.get('name', ''))
|
||||
descriptions.append(f'<b>{name}</b>\n{html.escape(desc)}')
|
||||
|
||||
if not descriptions:
|
||||
return base_text
|
||||
@@ -830,16 +835,16 @@ async def confirm_add_countries_to_subscription(
|
||||
discounted_per_month = server_price
|
||||
discount_per_month = 0
|
||||
|
||||
charged_price, charged_months = calculate_prorated_price(
|
||||
charged_price, charged_days = calculate_prorated_price(
|
||||
discounted_per_month,
|
||||
subscription.end_date,
|
||||
)
|
||||
|
||||
total_price += charged_price
|
||||
total_discount_value += discount_per_month * charged_months
|
||||
new_countries_names.append(country['name'])
|
||||
total_discount_value += int(discount_per_month * charged_days / 30)
|
||||
new_countries_names.append(html.escape(country['name']))
|
||||
if country['uuid'] in removed_countries:
|
||||
removed_countries_names.append(country['name'])
|
||||
removed_countries_names.append(html.escape(country['name']))
|
||||
|
||||
if new_countries and db_user.balance_kopeks < total_price:
|
||||
missing_kopeks = total_price - db_user.balance_kopeks
|
||||
|
||||
@@ -27,7 +27,6 @@ from app.services.user_cart_service import user_cart_service
|
||||
from app.utils.pagination import paginate_list
|
||||
from app.utils.pricing_utils import (
|
||||
apply_percentage_discount,
|
||||
get_remaining_months,
|
||||
)
|
||||
from app.utils.subscription_utils import (
|
||||
get_display_subscription_link,
|
||||
@@ -83,7 +82,7 @@ async def get_servers_display_names(squad_uuids: list[str]) -> str:
|
||||
for uuid in squad_uuids:
|
||||
server = await get_server_squad_by_uuid(db, uuid)
|
||||
if server:
|
||||
server_names.append(server.display_name)
|
||||
server_names.append(html_mod.escape(server.display_name))
|
||||
logger.debug('Найден сервер в БД', uuid=uuid, display_name=server.display_name)
|
||||
else:
|
||||
logger.warning('Сервер с UUID не найден в БД', uuid=uuid)
|
||||
@@ -93,7 +92,7 @@ async def get_servers_display_names(squad_uuids: list[str]) -> str:
|
||||
for uuid in squad_uuids:
|
||||
for country in countries:
|
||||
if country['uuid'] == uuid:
|
||||
server_names.append(country['name'])
|
||||
server_names.append(html_mod.escape(country['name']))
|
||||
logger.debug('Найден сервер в кэше', uuid=uuid, country=country['name'])
|
||||
break
|
||||
|
||||
@@ -278,11 +277,7 @@ async def confirm_change_devices(callback: types.CallbackQuery, db_user: User, d
|
||||
|
||||
# Используем max_device_limit из тарифа если есть, иначе глобальную настройку
|
||||
tariff_max_devices = getattr(tariff, 'max_device_limit', None) if tariff else None
|
||||
effective_max = (
|
||||
tariff_max_devices
|
||||
if tariff_max_devices
|
||||
else (settings.MAX_DEVICES_LIMIT if settings.MAX_DEVICES_LIMIT > 0 else None)
|
||||
)
|
||||
effective_max = tariff_max_devices or (settings.MAX_DEVICES_LIMIT if settings.MAX_DEVICES_LIMIT > 0 else None)
|
||||
if effective_max and new_devices_count > effective_max:
|
||||
await callback.answer(
|
||||
texts.t(
|
||||
@@ -551,13 +546,13 @@ async def execute_change_devices(callback: types.CallbackQuery, db_user: User, d
|
||||
)
|
||||
return
|
||||
|
||||
charged_months = get_remaining_months(subscription.end_date)
|
||||
charged_days = max(1, (subscription.end_date - datetime.now(UTC)).days)
|
||||
await create_transaction(
|
||||
db=db,
|
||||
user_id=db_user.id,
|
||||
type=TransactionType.SUBSCRIPTION_PAYMENT,
|
||||
amount_kopeks=price,
|
||||
description=f'Изменение устройств с {current_devices} до {new_devices_count} на {charged_months} мес',
|
||||
description=f'Изменение устройств с {current_devices} до {new_devices_count} за {charged_days} дн.',
|
||||
)
|
||||
|
||||
# Re-lock subscription after subtract_user_balance committed (released all locks)
|
||||
@@ -572,11 +567,7 @@ async def execute_change_devices(callback: types.CallbackQuery, db_user: User, d
|
||||
# Re-validate: prevent double-charge and max-limit violation
|
||||
if new_devices_count > current_devices:
|
||||
tariff_max_recheck = getattr(tariff, 'max_device_limit', None) if tariff else None
|
||||
max_devices = (
|
||||
tariff_max_recheck
|
||||
if tariff_max_recheck
|
||||
else (settings.MAX_DEVICES_LIMIT if settings.MAX_DEVICES_LIMIT > 0 else None)
|
||||
)
|
||||
max_devices = tariff_max_recheck or (settings.MAX_DEVICES_LIMIT if settings.MAX_DEVICES_LIMIT > 0 else None)
|
||||
if max_devices and new_devices_count > max_devices:
|
||||
if price > 0:
|
||||
user_refund = await db.execute(
|
||||
@@ -615,7 +606,7 @@ async def execute_change_devices(callback: types.CallbackQuery, db_user: User, d
|
||||
|
||||
await db.commit()
|
||||
|
||||
# Реактивируем подписку если она была DISABLED (например, после LIMITED в RemnaWave)
|
||||
# Реактивируем подписку если она была DISABLED/EXPIRED (например, после LIMITED/EXPIRED в RemnaWave)
|
||||
from app.database.crud.subscription import reactivate_subscription
|
||||
|
||||
await reactivate_subscription(db, subscription)
|
||||
@@ -623,6 +614,10 @@ async def execute_change_devices(callback: types.CallbackQuery, db_user: User, d
|
||||
subscription_service = SubscriptionService()
|
||||
await subscription_service.update_remnawave_user(db, subscription)
|
||||
|
||||
# Явно включаем пользователя на панели (PATCH может не снять LIMITED-статус)
|
||||
if db_user.remnawave_uuid and subscription.status == 'active':
|
||||
await subscription_service.enable_remnawave_user(db_user.remnawave_uuid)
|
||||
|
||||
# При уменьшении лимита - удалить лишние устройства (последние подключённые)
|
||||
devices_reset_count = 0
|
||||
if new_devices_count < current_devices and db_user.remnawave_uuid:
|
||||
@@ -1140,11 +1135,7 @@ async def confirm_add_devices(callback: types.CallbackQuery, db_user: User, db:
|
||||
|
||||
# Используем max_device_limit из тарифа если есть, иначе глобальную настройку
|
||||
tariff_max_devices = getattr(tariff, 'max_device_limit', None) if tariff else None
|
||||
effective_max = (
|
||||
tariff_max_devices
|
||||
if tariff_max_devices
|
||||
else (settings.MAX_DEVICES_LIMIT if settings.MAX_DEVICES_LIMIT > 0 else None)
|
||||
)
|
||||
effective_max = tariff_max_devices or (settings.MAX_DEVICES_LIMIT if settings.MAX_DEVICES_LIMIT > 0 else None)
|
||||
if effective_max and new_total_devices > effective_max:
|
||||
await callback.answer(
|
||||
texts.t(
|
||||
@@ -1279,11 +1270,7 @@ async def confirm_add_devices(callback: types.CallbackQuery, db_user: User, db:
|
||||
actual_current = subscription.device_limit or 1
|
||||
actual_new = actual_current + devices_count
|
||||
tariff_max_recheck = getattr(tariff, 'max_device_limit', None) if tariff else None
|
||||
max_devices = (
|
||||
tariff_max_recheck
|
||||
if tariff_max_recheck
|
||||
else (settings.MAX_DEVICES_LIMIT if settings.MAX_DEVICES_LIMIT > 0 else None)
|
||||
)
|
||||
max_devices = tariff_max_recheck or (settings.MAX_DEVICES_LIMIT if settings.MAX_DEVICES_LIMIT > 0 else None)
|
||||
if max_devices and actual_new > max_devices:
|
||||
# Concurrent purchase exceeded limit — refund
|
||||
user_refund = await db.execute(
|
||||
@@ -1302,7 +1289,7 @@ async def confirm_add_devices(callback: types.CallbackQuery, db_user: User, db:
|
||||
subscription.updated_at = datetime.now(UTC)
|
||||
await db.commit()
|
||||
|
||||
# Реактивируем подписку если она была DISABLED (например, после LIMITED в RemnaWave)
|
||||
# Реактивируем подписку если она была DISABLED/EXPIRED (например, после LIMITED/EXPIRED в RemnaWave)
|
||||
from app.database.crud.subscription import reactivate_subscription
|
||||
|
||||
await reactivate_subscription(db, subscription)
|
||||
@@ -1310,6 +1297,10 @@ async def confirm_add_devices(callback: types.CallbackQuery, db_user: User, db:
|
||||
subscription_service = SubscriptionService()
|
||||
await subscription_service.update_remnawave_user(db, subscription)
|
||||
|
||||
# Явно включаем пользователя на панели (PATCH может не снять LIMITED-статус)
|
||||
if db_user.remnawave_uuid and subscription.status == 'active':
|
||||
await subscription_service.enable_remnawave_user(db_user.remnawave_uuid)
|
||||
|
||||
await create_transaction(
|
||||
db=db,
|
||||
user_id=db_user.id,
|
||||
|
||||
@@ -27,6 +27,7 @@ async def send_purchase_notification(
|
||||
transaction_id: int,
|
||||
period_days: int,
|
||||
was_trial_conversion: bool = False,
|
||||
purchase_type: str | None = None,
|
||||
):
|
||||
try:
|
||||
from app.database.crud.transaction import get_transaction_by_id
|
||||
@@ -35,7 +36,13 @@ async def send_purchase_notification(
|
||||
if transaction:
|
||||
notification_service = AdminNotificationService(callback.bot)
|
||||
await notification_service.send_subscription_purchase_notification(
|
||||
db, db_user, subscription, transaction, period_days, was_trial_conversion
|
||||
db,
|
||||
db_user,
|
||||
subscription,
|
||||
transaction,
|
||||
period_days,
|
||||
was_trial_conversion,
|
||||
purchase_type=purchase_type,
|
||||
)
|
||||
except Exception as e:
|
||||
logger.error('Ошибка отправки уведомления о покупке', error=e)
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import html
|
||||
from datetime import UTC, datetime
|
||||
from typing import Any
|
||||
|
||||
@@ -74,7 +75,7 @@ async def _prepare_subscription_summary(
|
||||
if country['uuid'] in selected_country_ids:
|
||||
server_price_per_month = country['price_kopeks']
|
||||
countries_price_per_month += server_price_per_month
|
||||
selected_countries_names.append(country['name'])
|
||||
selected_countries_names.append(html.escape(country['name']))
|
||||
server_monthly_prices.append(server_price_per_month)
|
||||
|
||||
servers_discount_percent = db_user.get_promo_discount(
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import html
|
||||
from datetime import UTC, datetime, timedelta
|
||||
from typing import Any
|
||||
|
||||
@@ -313,7 +314,7 @@ async def show_subscription_info(callback: types.CallbackQuery, db_user: User, d
|
||||
devices_used_str = str(devices_used)
|
||||
|
||||
servers_names = await get_servers_display_names(subscription.connected_squads)
|
||||
servers_display = servers_names if servers_names else texts.t('SUBSCRIPTION_NO_SERVERS', 'Нет серверов')
|
||||
servers_display = servers_names or texts.t('SUBSCRIPTION_NO_SERVERS', 'Нет серверов')
|
||||
|
||||
# Получаем информацию о тарифе для режима тарифов
|
||||
tariff_info_block = ''
|
||||
@@ -623,7 +624,7 @@ async def show_trial_offer(callback: types.CallbackQuery, db_user: User, db: Asy
|
||||
tariff_squads = await get_server_squads_by_uuids(db, trial_tariff.allowed_squads)
|
||||
if tariff_squads:
|
||||
if len(tariff_squads) == 1:
|
||||
trial_server_name = tariff_squads[0].display_name
|
||||
trial_server_name = html.escape(tariff_squads[0].display_name)
|
||||
else:
|
||||
trial_server_name = texts.t(
|
||||
'TRIAL_SERVER_RANDOM_POOL',
|
||||
@@ -633,7 +634,7 @@ async def show_trial_offer(callback: types.CallbackQuery, db_user: User, db: Asy
|
||||
trial_squads = await get_trial_eligible_server_squads(db, include_unavailable=True)
|
||||
if trial_squads:
|
||||
if len(trial_squads) == 1:
|
||||
trial_server_name = trial_squads[0].display_name
|
||||
trial_server_name = html.escape(trial_squads[0].display_name)
|
||||
else:
|
||||
trial_server_name = texts.t(
|
||||
'TRIAL_SERVER_RANDOM_POOL',
|
||||
@@ -2821,7 +2822,13 @@ async def confirm_purchase(callback: types.CallbackQuery, state: FSMContext, db_
|
||||
try:
|
||||
notification_service = AdminNotificationService(callback.bot)
|
||||
await notification_service.send_subscription_purchase_notification(
|
||||
db, db_user, subscription, transaction, period_days, was_trial_conversion
|
||||
db,
|
||||
db_user,
|
||||
subscription,
|
||||
transaction,
|
||||
period_days,
|
||||
was_trial_conversion,
|
||||
purchase_type='renewal' if existing_subscription else 'first_purchase',
|
||||
)
|
||||
except Exception as e:
|
||||
logger.error('Ошибка отправки уведомления о покупке', error=e)
|
||||
|
||||
@@ -909,6 +909,7 @@ async def handle_custom_confirm(
|
||||
custom_days,
|
||||
was_trial_conversion=False,
|
||||
amount_kopeks=total_price,
|
||||
purchase_type='renewal' if existing_subscription else 'first_purchase',
|
||||
)
|
||||
except Exception as e:
|
||||
logger.error('Ошибка отправки уведомления админу', error=e)
|
||||
@@ -1221,6 +1222,7 @@ async def confirm_tariff_purchase(
|
||||
period,
|
||||
was_trial_conversion=False,
|
||||
amount_kopeks=final_price,
|
||||
purchase_type='renewal' if existing_subscription else 'first_purchase',
|
||||
)
|
||||
except Exception as e:
|
||||
logger.error('Ошибка отправки уведомления админу', error=e)
|
||||
@@ -1404,6 +1406,7 @@ async def confirm_daily_tariff_purchase(
|
||||
1, # 1 день
|
||||
was_trial_conversion=False,
|
||||
amount_kopeks=daily_price,
|
||||
purchase_type='renewal' if existing_subscription else 'first_purchase',
|
||||
)
|
||||
except Exception as e:
|
||||
logger.error('Ошибка отправки уведомления админу', error=e)
|
||||
@@ -1765,6 +1768,7 @@ async def confirm_tariff_extend(
|
||||
period,
|
||||
was_trial_conversion=False,
|
||||
amount_kopeks=final_price,
|
||||
purchase_type='renewal',
|
||||
)
|
||||
except Exception as e:
|
||||
logger.error('Ошибка отправки уведомления админу', error=e)
|
||||
|
||||
@@ -26,7 +26,6 @@ from app.states import SubscriptionStates
|
||||
from app.utils.pricing_utils import (
|
||||
apply_percentage_discount,
|
||||
calculate_prorated_price,
|
||||
get_remaining_months,
|
||||
)
|
||||
|
||||
from .common import (
|
||||
@@ -482,7 +481,7 @@ async def add_traffic(callback: types.CallbackQuery, db_user: User, db: AsyncSes
|
||||
|
||||
discounted_per_month = discount_result['discounted']
|
||||
discount_per_month = discount_result['discount']
|
||||
charged_months = 1
|
||||
charged_days = 30
|
||||
|
||||
# На тарифах пакеты трафика покупаются на 1 месяц (30 дней),
|
||||
# цена в тарифе уже месячная — не умножаем на оставшиеся месяцы подписки.
|
||||
@@ -492,14 +491,14 @@ async def add_traffic(callback: types.CallbackQuery, db_user: User, db: AsyncSes
|
||||
if is_tariff_mode:
|
||||
price = discounted_per_month
|
||||
elif subscription:
|
||||
price, charged_months = calculate_prorated_price(
|
||||
price, charged_days = calculate_prorated_price(
|
||||
discounted_per_month,
|
||||
subscription.end_date,
|
||||
)
|
||||
else:
|
||||
price = discounted_per_month
|
||||
|
||||
total_discount_value = discount_per_month * charged_months
|
||||
total_discount_value = int(discount_per_month * charged_days / 30)
|
||||
|
||||
if db_user.balance_kopeks < price:
|
||||
missing_kopeks = price - db_user.balance_kopeks
|
||||
@@ -578,12 +577,16 @@ async def add_traffic(callback: types.CallbackQuery, db_user: User, db: AsyncSes
|
||||
# add_subscription_traffic уже создаёт TrafficPurchase и обновляет все необходимые поля
|
||||
await add_subscription_traffic(db, subscription, traffic_gb)
|
||||
|
||||
# Реактивируем подписку если она была DISABLED (например, после LIMITED в RemnaWave)
|
||||
# Реактивируем подписку если она была DISABLED/EXPIRED (например, после LIMITED/EXPIRED в RemnaWave)
|
||||
await reactivate_subscription(db, subscription)
|
||||
|
||||
subscription_service = SubscriptionService()
|
||||
await subscription_service.update_remnawave_user(db, subscription)
|
||||
|
||||
# Явно включаем пользователя на панели (PATCH может не снять LIMITED-статус)
|
||||
if db_user.remnawave_uuid and subscription.status == 'active':
|
||||
await subscription_service.enable_remnawave_user(db_user.remnawave_uuid)
|
||||
|
||||
await create_transaction(
|
||||
db=db,
|
||||
user_id=db_user.id,
|
||||
@@ -716,8 +719,9 @@ async def confirm_switch_traffic(callback: types.CallbackQuery, db_user: User, d
|
||||
old_price_per_month = settings.get_traffic_price(base_traffic)
|
||||
new_price_per_month = settings.get_traffic_price(new_traffic_gb)
|
||||
|
||||
months_remaining = get_remaining_months(subscription.end_date)
|
||||
period_hint_days = months_remaining * 30 if months_remaining > 0 else None
|
||||
now = datetime.now(UTC)
|
||||
days_remaining = max(1, (subscription.end_date - now).days)
|
||||
period_hint_days = days_remaining if days_remaining > 0 else None
|
||||
traffic_discount_percent = _get_addon_discount_percent_for_user(
|
||||
db_user,
|
||||
'traffic',
|
||||
@@ -736,7 +740,8 @@ async def confirm_switch_traffic(callback: types.CallbackQuery, db_user: User, d
|
||||
discount_savings_per_month = (new_price_per_month - old_price_per_month) - price_difference_per_month
|
||||
|
||||
if price_difference_per_month > 0:
|
||||
total_price_difference = price_difference_per_month * months_remaining
|
||||
total_price_difference = int(price_difference_per_month * days_remaining / 30)
|
||||
total_price_difference = max(100, total_price_difference)
|
||||
|
||||
if db_user.balance_kopeks < total_price_difference:
|
||||
missing_kopeks = total_price_difference - db_user.balance_kopeks
|
||||
@@ -750,7 +755,7 @@ async def confirm_switch_traffic(callback: types.CallbackQuery, db_user: User, d
|
||||
'Выберите способ пополнения. Сумма подставится автоматически.'
|
||||
),
|
||||
).format(
|
||||
required=f'{texts.format_price(total_price_difference)} (за {months_remaining} мес)',
|
||||
required=f'{texts.format_price(total_price_difference)} (за {days_remaining} дн.)',
|
||||
balance=texts.format_price(db_user.balance_kopeks),
|
||||
missing=texts.format_price(missing_kopeks),
|
||||
)
|
||||
@@ -767,9 +772,9 @@ async def confirm_switch_traffic(callback: types.CallbackQuery, db_user: User, d
|
||||
return
|
||||
|
||||
action_text = f'увеличить до {texts.format_traffic(new_traffic_gb)}'
|
||||
cost_text = f'Доплата: {texts.format_price(total_price_difference)} (за {months_remaining} мес)'
|
||||
cost_text = f'Доплата: {texts.format_price(total_price_difference)} (за {days_remaining} дн.)'
|
||||
if discount_savings_per_month > 0:
|
||||
total_discount_savings = discount_savings_per_month * months_remaining
|
||||
total_discount_savings = int(discount_savings_per_month * days_remaining / 30)
|
||||
cost_text += f' (скидка {traffic_discount_percent}%: -{texts.format_price(total_discount_savings)})'
|
||||
else:
|
||||
total_price_difference = 0
|
||||
@@ -811,13 +816,13 @@ async def execute_switch_traffic(callback: types.CallbackQuery, db_user: User, d
|
||||
await callback.answer('⚠️ Ошибка списания средств', show_alert=True)
|
||||
return
|
||||
|
||||
months_remaining = get_remaining_months(subscription.end_date)
|
||||
days_remaining = max(1, (subscription.end_date - datetime.now(UTC)).days)
|
||||
await create_transaction(
|
||||
db=db,
|
||||
user_id=db_user.id,
|
||||
type=TransactionType.SUBSCRIPTION_PAYMENT,
|
||||
amount_kopeks=price_difference,
|
||||
description=f'Переключение трафика с {current_traffic}GB на {new_traffic_gb}GB на {months_remaining} мес',
|
||||
description=f'Переключение трафика с {current_traffic}GB на {new_traffic_gb}GB за {days_remaining} дн.',
|
||||
)
|
||||
|
||||
subscription.traffic_limit_gb = new_traffic_gb
|
||||
@@ -833,12 +838,16 @@ async def execute_switch_traffic(callback: types.CallbackQuery, db_user: User, d
|
||||
|
||||
await db.commit()
|
||||
|
||||
# Реактивируем подписку если она была DISABLED (например, после LIMITED в RemnaWave)
|
||||
# Реактивируем подписку если она была DISABLED/EXPIRED (например, после LIMITED/EXPIRED в RemnaWave)
|
||||
await reactivate_subscription(db, subscription)
|
||||
|
||||
subscription_service = SubscriptionService()
|
||||
await subscription_service.update_remnawave_user(db, subscription)
|
||||
|
||||
# Явно включаем пользователя на панели (PATCH может не снять LIMITED-статус)
|
||||
if db_user.remnawave_uuid and subscription.status == 'active':
|
||||
await subscription_service.enable_remnawave_user(db_user.remnawave_uuid)
|
||||
|
||||
await db.refresh(db_user)
|
||||
await db.refresh(subscription)
|
||||
|
||||
|
||||
@@ -504,7 +504,7 @@ def _split_text_into_pages(header: str, message_blocks: list[str], max_len: int
|
||||
if current.strip():
|
||||
pages.append(current)
|
||||
|
||||
return pages if pages else [header]
|
||||
return pages or [header]
|
||||
|
||||
|
||||
async def view_ticket(callback: types.CallbackQuery, db_user: User, db: AsyncSession):
|
||||
|
||||
+46
-38
@@ -1693,6 +1693,18 @@ def get_payment_methods_keyboard(amount_kopeks: int, language: str = DEFAULT_LAN
|
||||
)
|
||||
has_direct_payment_methods = True
|
||||
|
||||
if settings.is_riopay_enabled():
|
||||
riopay_name = settings.get_riopay_display_name()
|
||||
keyboard.append(
|
||||
[
|
||||
InlineKeyboardButton(
|
||||
text=texts.t('PAYMENT_RIOPAY', f'💳 Банковская карта ({riopay_name})'),
|
||||
callback_data=_build_callback('riopay'),
|
||||
)
|
||||
]
|
||||
)
|
||||
has_direct_payment_methods = True
|
||||
|
||||
if settings.is_support_topup_enabled():
|
||||
keyboard.append(
|
||||
[
|
||||
@@ -1957,18 +1969,20 @@ def get_add_traffic_keyboard(
|
||||
discount_percent: int = 0,
|
||||
) -> InlineKeyboardMarkup:
|
||||
from app.config import settings
|
||||
from app.utils.pricing_utils import get_remaining_months
|
||||
|
||||
texts = get_texts(language)
|
||||
language_code = (language or DEFAULT_LANGUAGE).split('-')[0].lower()
|
||||
use_russian_fallback = language_code in {'ru', 'fa'}
|
||||
|
||||
months_multiplier = 1
|
||||
period_text = ''
|
||||
# Считаем по дням (как в кабинете и подтверждении)
|
||||
if subscription_end_date:
|
||||
months_multiplier = get_remaining_months(subscription_end_date)
|
||||
if months_multiplier > 1:
|
||||
period_text = f' (за {months_multiplier} мес)'
|
||||
now = datetime.now(UTC)
|
||||
days_left = max(1, (subscription_end_date - now).days)
|
||||
price_multiplier = days_left / 30
|
||||
period_text = f' (за {days_left} дн.)' if days_left > 1 else ' (за 1 день)'
|
||||
else:
|
||||
price_multiplier = 1
|
||||
period_text = ''
|
||||
|
||||
packages = settings.get_traffic_topup_packages()
|
||||
enabled_packages = [pkg for pkg in packages if pkg['enabled'] and pkg['price'] > 0]
|
||||
@@ -1995,8 +2009,9 @@ def get_add_traffic_keyboard(
|
||||
price_per_month,
|
||||
discount_percent,
|
||||
)
|
||||
total_price = discounted_per_month * months_multiplier
|
||||
total_discount = discount_per_month * months_multiplier
|
||||
total_price = int(discounted_per_month * price_multiplier)
|
||||
total_price = max(100, total_price) if total_price > 0 else 0
|
||||
total_discount = int(discount_per_month * price_multiplier)
|
||||
|
||||
if gb == 0:
|
||||
if use_russian_fallback:
|
||||
@@ -2094,30 +2109,18 @@ def get_change_devices_keyboard(
|
||||
tariff=None, # Тариф для цены за устройство
|
||||
) -> InlineKeyboardMarkup:
|
||||
from app.config import settings
|
||||
from app.utils.pricing_utils import get_remaining_months
|
||||
|
||||
texts = get_texts(language)
|
||||
|
||||
# Проверяем является ли тариф суточным
|
||||
is_daily_tariff = tariff and getattr(tariff, 'is_daily', False)
|
||||
|
||||
# Для суточных тарифов считаем по дням, для обычных - по месяцам
|
||||
if is_daily_tariff and subscription_end_date:
|
||||
# Суточный тариф: цена за оставшиеся дни (обычно 1 день)
|
||||
# Считаем по дням (как в кабинете и подтверждении)
|
||||
if subscription_end_date:
|
||||
now = datetime.now(UTC)
|
||||
days_left = max(1, (subscription_end_date - now).days)
|
||||
# Множитель = days_left / 30 (как в кабинете)
|
||||
price_multiplier = days_left / 30
|
||||
period_text = f' (за {days_left} дн.)' if days_left > 1 else ' (за 1 день)'
|
||||
else:
|
||||
# Обычный тариф: цена за оставшиеся месяцы
|
||||
months_multiplier = 1
|
||||
price_multiplier = 1
|
||||
period_text = ''
|
||||
if subscription_end_date:
|
||||
months_multiplier = get_remaining_months(subscription_end_date)
|
||||
if months_multiplier > 1:
|
||||
period_text = f' (за {months_multiplier} мес)'
|
||||
price_multiplier = months_multiplier
|
||||
|
||||
# Используем цену из тарифа если есть, иначе глобальную настройку
|
||||
tariff_device_price = getattr(tariff, 'device_price_kopeks', None) if tariff else None
|
||||
@@ -2266,18 +2269,21 @@ def get_manage_countries_keyboard(
|
||||
subscription_end_date: datetime = None,
|
||||
discount_percent: int = 0,
|
||||
) -> InlineKeyboardMarkup:
|
||||
from app.utils.pricing_utils import get_remaining_months
|
||||
|
||||
texts = get_texts(language)
|
||||
|
||||
months_multiplier = 1
|
||||
# Считаем по дням (как в кабинете и подтверждении)
|
||||
if subscription_end_date:
|
||||
months_multiplier = get_remaining_months(subscription_end_date)
|
||||
now = datetime.now(UTC)
|
||||
days_left = max(1, (subscription_end_date - now).days)
|
||||
price_multiplier = days_left / 30
|
||||
logger.info(
|
||||
'🔍 Расчет для управления странами: осталось месяцев до',
|
||||
months_multiplier=months_multiplier,
|
||||
'🔍 Расчет для управления странами: осталось дней до',
|
||||
days_left=days_left,
|
||||
subscription_end_date=subscription_end_date,
|
||||
)
|
||||
else:
|
||||
price_multiplier = 1
|
||||
days_left = 30
|
||||
|
||||
buttons = []
|
||||
total_cost = 0
|
||||
@@ -2302,26 +2308,28 @@ def get_manage_countries_keyboard(
|
||||
icon = '➖'
|
||||
elif uuid in selected:
|
||||
icon = '➕'
|
||||
total_cost += discounted_per_month * months_multiplier
|
||||
total_cost += int(discounted_per_month * price_multiplier)
|
||||
else:
|
||||
icon = '⚪'
|
||||
|
||||
if uuid not in current_subscription_countries and uuid in selected:
|
||||
total_price = discounted_per_month * months_multiplier
|
||||
if months_multiplier > 1:
|
||||
price_text = f' ({discounted_per_month // 100}₽/мес × {months_multiplier} = {total_price // 100}₽)'
|
||||
total_price = int(discounted_per_month * price_multiplier)
|
||||
total_price = max(100, total_price) if total_price > 0 else 0
|
||||
if days_left > 30:
|
||||
price_text = f' ({discounted_per_month // 100}₽/мес × {days_left} дн. = {total_price // 100}₽)'
|
||||
logger.info(
|
||||
'🔍 Сервер : ₽/мес × мес = ₽ (скидка ₽)',
|
||||
'🔍 Сервер : ₽/мес × дн./30 = ₽ (скидка ₽)',
|
||||
name=name,
|
||||
discounted_per_month=discounted_per_month / 100,
|
||||
months_multiplier=months_multiplier,
|
||||
days_left=days_left,
|
||||
total_price=total_price / 100,
|
||||
discount_per_month=(discount_per_month * months_multiplier) / 100,
|
||||
discount_per_month=int(discount_per_month * price_multiplier) / 100,
|
||||
)
|
||||
else:
|
||||
price_text = f' ({total_price // 100}₽)'
|
||||
if discount_percent > 0 and discount_per_month * months_multiplier > 0:
|
||||
price_text += f' (скидка {discount_percent}%: -{(discount_per_month * months_multiplier) // 100}₽)'
|
||||
total_discount_for_server = int(discount_per_month * price_multiplier)
|
||||
if discount_percent > 0 and total_discount_for_server > 0:
|
||||
price_text += f' (скидка {discount_percent}%: -{total_discount_for_server // 100}₽)'
|
||||
display_name = f'{icon} {name}{price_text}'
|
||||
else:
|
||||
display_name = f'{icon} {name}'
|
||||
|
||||
@@ -889,8 +889,16 @@
|
||||
"AUTOPAY_SET_DAYS_BUTTON": "⚙️ Configure days",
|
||||
"AUTOPAY_STATUS_DISABLED": "disabled",
|
||||
"AUTOPAY_STATUS_ENABLED": "enabled",
|
||||
"AUTOPAY_STATUS_CARD_ACTIVE": "✅ Enabled — automatic card charge scheduled",
|
||||
"AUTOPAY_STATUS_NO_CARD": "✅ Enabled — subscription will renew automatically",
|
||||
"AUTOPAY_STATUS_OFF": "❌ Disabled — don't forget to renew manually!",
|
||||
"AUTOPAY_ACTION_CHECK_BALANCE": "💰 Make sure you have enough balance: {balance}",
|
||||
"AUTOPAY_ACTION_ENABLE": "💡 Enable autopay or renew your subscription manually",
|
||||
"AUTOPAY_ACTION_RENEW": "💡 Renew your subscription manually",
|
||||
"AUTOPAY_SUCCESS": "\n✅ <b>Autopay completed</b>\n\nYour subscription was automatically renewed for {days} days.\nCharged from balance: {amount}\n",
|
||||
"AUTOPAY_TOGGLE_SUCCESS": "✅ Autopay {status}!",
|
||||
"RECURRENT_TOPUP_SUCCESS": "✅ <b>Auto-payment completed</b>\n\nBalance topped up by {amount} for subscription renewal.",
|
||||
"RECURRENT_TOPUP_FAILED": "❌ <b>Auto-payment failed</b>\n\nCould not charge {amount} from any saved card for subscription renewal.\n\nPlease top up your balance manually to avoid service interruption.",
|
||||
"BACK": "⬅️ Back",
|
||||
"BACK_BUTTON": "◀️ Back",
|
||||
"BACK_TO_MAIN_MENU_BUTTON": "⬅️ Back to main menu",
|
||||
@@ -1191,6 +1199,9 @@
|
||||
"PAYMENT_METHOD_YOOKASSA_SBP_NAME": "🏦 <b>SBP (YooKassa)</b>",
|
||||
"PAYMENT_METHOD_WATA_DESCRIPTION": "via WATA",
|
||||
"PAYMENT_METHOD_WATA_NAME": "💳 <b>Bank card (WATA)</b>",
|
||||
"PAYMENT_METHOD_RIOPAY_DESCRIPTION": "via RioPay",
|
||||
"PAYMENT_METHOD_RIOPAY_NAME": "💳 <b>Bank card (RioPay)</b>",
|
||||
"PAYMENT_RIOPAY": "💳 Bank card (RioPay)",
|
||||
"PAYMENT_HELEKET_MARKUP_LABEL": "Provider markup",
|
||||
"PAYMENT_HELEKET_DISCOUNT_LABEL": "Provider discount",
|
||||
"PAYMENT_RETURN_HOME_BUTTON": "🏠 Main menu",
|
||||
@@ -1308,6 +1319,7 @@
|
||||
"REFERRAL_RECENT_EARNINGS_ITEM": "• {reason}: <b>{amount}</b> from {referral_name}",
|
||||
"REFERRAL_REWARDS_HEADER": "🎁 <b>How rewards work:</b>",
|
||||
"REFERRAL_REWARD_COMMISSION": "• Commission from each referral top-up: <b>{percent}%</b>",
|
||||
"REFERRAL_REWARD_COMMISSION_LIMITED": "• Commission from the first {max_payments} referral top-ups: <b>{percent}%</b>",
|
||||
"REFERRAL_REWARD_INVITER": "• You receive on the referral's first top-up: <b>{bonus}</b>",
|
||||
"REFERRAL_REWARD_NEW_USER": "• New user receives: <b>{bonus}</b> on the first top-up from <b>{minimum}</b>",
|
||||
"REFERRAL_SHARE_BUTTON": "📤 Share",
|
||||
|
||||
@@ -909,8 +909,16 @@
|
||||
"AUTOPAY_SET_DAYS_BUTTON": "⚙️ تنظیم روزها",
|
||||
"AUTOPAY_STATUS_DISABLED": "غیرفعال",
|
||||
"AUTOPAY_STATUS_ENABLED": "فعال",
|
||||
"AUTOPAY_STATUS_CARD_ACTIVE": "✅ فعال — کارت به صورت خودکار شارژ میشود",
|
||||
"AUTOPAY_STATUS_NO_CARD": "✅ فعال — اشتراک به صورت خودکار تمدید میشود",
|
||||
"AUTOPAY_STATUS_OFF": "❌ غیرفعال — فراموش نکنید دستی تمدید کنید!",
|
||||
"AUTOPAY_ACTION_CHECK_BALANCE": "💰 مطمئن شوید موجودی کافی دارید: {balance}",
|
||||
"AUTOPAY_ACTION_ENABLE": "💡 پرداخت خودکار را فعال کنید یا اشتراک را دستی تمدید کنید",
|
||||
"AUTOPAY_ACTION_RENEW": "💡 اشتراک را دستی تمدید کنید",
|
||||
"AUTOPAY_SUCCESS": "\n✅ <b>پرداخت خودکار انجام شد</b>\n\nاشتراک {days} روز تمدید شد.\nکسر: {amount}\n",
|
||||
"AUTOPAY_TOGGLE_SUCCESS": "✅ پرداخت خودکار {status}!",
|
||||
"RECURRENT_TOPUP_SUCCESS": "✅ <b>پرداخت خودکار انجام شد</b>\n\nموجودی به مبلغ {amount} برای تمدید اشتراک شارژ شد.",
|
||||
"RECURRENT_TOPUP_FAILED": "❌ <b>پرداخت خودکار ناموفق بود</b>\n\nامکان کسر {amount} از هیچ کارت ذخیره شدهای برای تمدید اشتراک وجود نداشت.\n\nلطفاً موجودی را به صورت دستی شارژ کنید.",
|
||||
"BACK": "⬅️ قبلی",
|
||||
"BACK_BUTTON": "◀️ بازگشت",
|
||||
"BACK_TO_MAIN_MENU_BUTTON": "⬅️ منوی اصلی",
|
||||
@@ -1212,6 +1220,9 @@
|
||||
"PAYMENT_METHOD_YOOKASSA_SBP_NAME": "🏦 <b>پرداخت سریع (YooKassa)</b>",
|
||||
"PAYMENT_METHOD_WATA_DESCRIPTION": "از طریق WATA",
|
||||
"PAYMENT_METHOD_WATA_NAME": "💳 <b>کارت بانکی (WATA)</b>",
|
||||
"PAYMENT_METHOD_RIOPAY_DESCRIPTION": "از طریق RioPay",
|
||||
"PAYMENT_METHOD_RIOPAY_NAME": "💳 <b>کارت بانکی (RioPay)</b>",
|
||||
"PAYMENT_RIOPAY": "💳 کارت بانکی (RioPay)",
|
||||
"PAYMENT_HELEKET_MARKUP_LABEL": "هزینه اضافی ارائهدهنده",
|
||||
"PAYMENT_HELEKET_DISCOUNT_LABEL": "تخفیف ارائهدهنده",
|
||||
"PAYMENT_RETURN_HOME_BUTTON": "🏠 صفحه اصلی",
|
||||
@@ -1329,6 +1340,7 @@
|
||||
"REFERRAL_RECENT_EARNINGS_ITEM": "• {reason}: <b>{amount}</b> از {referral_name}",
|
||||
"REFERRAL_REWARDS_HEADER": "🎁 <b>نحوه عملکرد پاداشها:</b>",
|
||||
"REFERRAL_REWARD_COMMISSION": "• کمیسیون از هر شارژ دعوتشده: <b>{percent}%</b>",
|
||||
"REFERRAL_REWARD_COMMISSION_LIMITED": "• کمیسیون از {max_payments} شارژ اول دعوتشده: <b>{percent}%</b>",
|
||||
"REFERRAL_REWARD_INVITER": "• پاداش اولین شارژ دعوتشده: <b>{bonus}</b>",
|
||||
"REFERRAL_REWARD_NEW_USER": "• کاربر جدید دریافت میکند: <b>{bonus}</b> با اولین شارژ از <b>{minimum}</b>",
|
||||
"REFERRAL_SHARE_BUTTON": "📤 اشتراکگذاری",
|
||||
@@ -1429,7 +1441,7 @@
|
||||
"SUBSCRIPTION_EXPIRED_SECOND_WAVE": "🔥 <b>تخفیف {percent}% برای تمدید</b>\n\nپیشنهاد را فعال کنید تا تخفیف اضافی بگیرید. با گروه تخفیف جمع شده و تا {expires_at} اعتبار دارد.",
|
||||
"SUBSCRIPTION_EXPIRED_THIRD_WAVE": "🎁 <b>تخفیف اختصاصی {percent}%</b>\n\n{trigger_days} روز بدون اشتراک گذشته. برگردید و تخفیف اضافی فعال کنید — با گروه تخفیف جمع شده و تا {expires_at} اعتبار دارد.",
|
||||
"SUBSCRIPTION_EXPIRING": "⏰ اشتراک بهزودی منقضی میشود!",
|
||||
"SUBSCRIPTION_EXPIRING_PAID": "⏰ اشتراک {days} روز دیگر منقضی میشود.",
|
||||
"SUBSCRIPTION_EXPIRING_PAID": "\n⚠️ <b>اشتراک تا {days_text} دیگر منقضی میشود!</b>\n\nاشتراک پولی شما در تاریخ {end_date} منقضی میشود.\n\n💳 <b>پرداخت خودکار:</b> {autopay_status}\n\n{action_text}\n",
|
||||
"SUBSCRIPTION_EXTEND": "🔄 تمدید اشتراک",
|
||||
"SUBSCRIPTION_HAPP_CRYPTOLINK_BLOCK": "<blockquote expandable><code>{crypto_link}</code></blockquote>",
|
||||
"SUBSCRIPTION_HAPP_LINK_PROMPT": "🔒 لینک اشتراک ایجاد شد. دکمه «اتصال» بزنید تا در Happ باز شود.",
|
||||
|
||||
@@ -909,8 +909,16 @@
|
||||
"AUTOPAY_SET_DAYS_BUTTON": "⚙️ Настроить дни",
|
||||
"AUTOPAY_STATUS_DISABLED": "выключен",
|
||||
"AUTOPAY_STATUS_ENABLED": "включен",
|
||||
"AUTOPAY_STATUS_CARD_ACTIVE": "✅ Включен — будет автоматическое списание с карты",
|
||||
"AUTOPAY_STATUS_NO_CARD": "✅ Включен — подписка продлится автоматически",
|
||||
"AUTOPAY_STATUS_OFF": "❌ Отключен — не забудьте продлить вручную!",
|
||||
"AUTOPAY_ACTION_CHECK_BALANCE": "💰 Убедитесь, что на балансе достаточно средств: {balance}",
|
||||
"AUTOPAY_ACTION_ENABLE": "💡 Включите автоплатеж или продлите подписку вручную",
|
||||
"AUTOPAY_ACTION_RENEW": "💡 Продлите подписку вручную",
|
||||
"AUTOPAY_SUCCESS": "\n✅ <b>Автоплатеж выполнен</b>\n\nВаша подписка автоматически продлена на {days} дней.\nСписано с баланса: {amount}\n",
|
||||
"AUTOPAY_TOGGLE_SUCCESS": "✅ Автоплатеж {status}!",
|
||||
"RECURRENT_TOPUP_SUCCESS": "✅ <b>Автоплатёж выполнен</b>\n\nБаланс пополнен на {amount} для продления подписки.",
|
||||
"RECURRENT_TOPUP_FAILED": "❌ <b>Автоплатёж не удался</b>\n\nНе удалось списать {amount} ни с одной сохранённой карты для продления подписки.\n\nПополните баланс вручную, чтобы подписка не прервалась.",
|
||||
"BACK": "⬅️ Назад",
|
||||
"BACK_BUTTON": "◀️ Назад",
|
||||
"BACK_TO_MAIN_MENU_BUTTON": "⬅️ В главное меню",
|
||||
@@ -1212,6 +1220,9 @@
|
||||
"PAYMENT_METHOD_YOOKASSA_SBP_NAME": "🏦 <b>СБП (YooKassa)</b>",
|
||||
"PAYMENT_METHOD_WATA_DESCRIPTION": "через WATA",
|
||||
"PAYMENT_METHOD_WATA_NAME": "💳 <b>Банковская карта (WATA)</b>",
|
||||
"PAYMENT_METHOD_RIOPAY_DESCRIPTION": "через RioPay",
|
||||
"PAYMENT_METHOD_RIOPAY_NAME": "💳 <b>Банковская карта (RioPay)</b>",
|
||||
"PAYMENT_RIOPAY": "💳 Банковская карта (RioPay)",
|
||||
"PAYMENT_HELEKET_MARKUP_LABEL": "Наценка провайдера",
|
||||
"PAYMENT_HELEKET_DISCOUNT_LABEL": "Скидка провайдера",
|
||||
"PAYMENT_RETURN_HOME_BUTTON": "🏠 На главную",
|
||||
@@ -1329,6 +1340,7 @@
|
||||
"REFERRAL_RECENT_EARNINGS_ITEM": "• {reason}: <b>{amount}</b> от {referral_name}",
|
||||
"REFERRAL_REWARDS_HEADER": "🎁 <b>Как работают награды:</b>",
|
||||
"REFERRAL_REWARD_COMMISSION": "• Комиссия с каждого пополнения реферала: <b>{percent}%</b>",
|
||||
"REFERRAL_REWARD_COMMISSION_LIMITED": "• Комиссия с первых {max_payments} пополнений реферала: <b>{percent}%</b>",
|
||||
"REFERRAL_REWARD_INVITER": "• Вы получаете при первом пополнении реферала: <b>{bonus}</b>",
|
||||
"REFERRAL_REWARD_NEW_USER": "• Новый пользователь получает: <b>{bonus}</b> при первом пополнении от <b>{minimum}</b>",
|
||||
"REFERRAL_SHARE_BUTTON": "📤 Поделиться",
|
||||
|
||||
@@ -831,8 +831,16 @@
|
||||
"AUTOPAY_SET_DAYS_BUTTON": "⚙️ Налаштувати дні",
|
||||
"AUTOPAY_STATUS_DISABLED": "вимкнено",
|
||||
"AUTOPAY_STATUS_ENABLED": "увімкнено",
|
||||
"AUTOPAY_STATUS_CARD_ACTIVE": "✅ Увімкнено — буде автоматичне списання з картки",
|
||||
"AUTOPAY_STATUS_NO_CARD": "✅ Увімкнено — підписка продовжиться автоматично",
|
||||
"AUTOPAY_STATUS_OFF": "❌ Вимкнено — не забудьте продовжити вручну!",
|
||||
"AUTOPAY_ACTION_CHECK_BALANCE": "💰 Переконайтеся, що на балансі достатньо коштів: {balance}",
|
||||
"AUTOPAY_ACTION_ENABLE": "💡 Увімкніть автоплатіж або продовжіть підписку вручну",
|
||||
"AUTOPAY_ACTION_RENEW": "💡 Продовжіть підписку вручну",
|
||||
"AUTOPAY_SUCCESS": "\n✅ <b>Автоплатіж виконано</b>\n\nВашу підписку автоматично продовжено на {days} днів.\nСписано з балансу: {amount}\n",
|
||||
"AUTOPAY_TOGGLE_SUCCESS": "✅ Автоплатіж {status}!",
|
||||
"RECURRENT_TOPUP_SUCCESS": "✅ <b>Автоплатіж виконано</b>\n\nБаланс поповнено на {amount} для продовження підписки.",
|
||||
"RECURRENT_TOPUP_FAILED": "❌ <b>Автоплатіж не вдався</b>\n\nНе вдалося списати {amount} з жодної збереженої картки для продовження підписки.\n\nПоповніть баланс вручну, щоб підписка не перервалася.",
|
||||
"BACK": "⬅️ Назад",
|
||||
"BACK_TO_MAIN_MENU_BUTTON": "⬅️ В головне меню",
|
||||
"BACK_TO_MENU": "🏠 В головне меню",
|
||||
@@ -1129,6 +1137,9 @@
|
||||
"PAYMENT_METHOD_YOOKASSA_SBP_NAME": "🏦 <b>СБП (YooKassa)</b>",
|
||||
"PAYMENT_METHOD_WATA_DESCRIPTION": "через WATA",
|
||||
"PAYMENT_METHOD_WATA_NAME": "💳 <b>Банківська картка (WATA)</b>",
|
||||
"PAYMENT_METHOD_RIOPAY_DESCRIPTION": "через RioPay",
|
||||
"PAYMENT_METHOD_RIOPAY_NAME": "💳 <b>Банківська картка (RioPay)</b>",
|
||||
"PAYMENT_RIOPAY": "💳 Банківська картка (RioPay)",
|
||||
"PAYMENT_HELEKET_MARKUP_LABEL": "Націнка провайдера",
|
||||
"PAYMENT_HELEKET_DISCOUNT_LABEL": "Знижка провайдера",
|
||||
"PAYMENT_RETURN_HOME_BUTTON": "🏠 На головну",
|
||||
@@ -1245,6 +1256,7 @@
|
||||
"REFERRAL_RECENT_EARNINGS_ITEM": "• {reason}: <b>{amount}</b> від {referral_name}",
|
||||
"REFERRAL_REWARDS_HEADER": "🎁 <b>Як працюють нагороди:</b>",
|
||||
"REFERRAL_REWARD_COMMISSION": "• Комісія з кожного поповнення реферала: <b>{percent}%</b>",
|
||||
"REFERRAL_REWARD_COMMISSION_LIMITED": "• Комісія з перших {max_payments} поповнень реферала: <b>{percent}%</b>",
|
||||
"REFERRAL_REWARD_INVITER": "• Ви отримуєте при першому поповненні реферала: <b>{bonus}</b>",
|
||||
"REFERRAL_REWARD_NEW_USER": "• Новий користувач отримує: <b>{bonus}</b> при першому поповненні від <b>{minimum}</b>",
|
||||
"REFERRAL_SHARE_BUTTON": "📤 Поділитися",
|
||||
|
||||
@@ -829,8 +829,16 @@
|
||||
"AUTOPAY_SET_DAYS_BUTTON": "⚙️设置天数",
|
||||
"AUTOPAY_STATUS_DISABLED": "已禁用",
|
||||
"AUTOPAY_STATUS_ENABLED": "已启用",
|
||||
"AUTOPAY_STATUS_CARD_ACTIVE": "✅ 已启用 — 将自动从银行卡扣款",
|
||||
"AUTOPAY_STATUS_NO_CARD": "✅ 已启用 — 订阅将自动续订",
|
||||
"AUTOPAY_STATUS_OFF": "❌ 已禁用 — 请别忘了手动续订!",
|
||||
"AUTOPAY_ACTION_CHECK_BALANCE": "💰 请确保余额充足:{balance}",
|
||||
"AUTOPAY_ACTION_ENABLE": "💡 请启用自动支付或手动续订",
|
||||
"AUTOPAY_ACTION_RENEW": "💡 请手动续订",
|
||||
"AUTOPAY_SUCCESS": "\n✅<b>自动支付成功</b>\n\n您的订阅已自动延长{days}天。\n已从余额扣除:{amount}\n",
|
||||
"AUTOPAY_TOGGLE_SUCCESS": "✅自动支付{status}!",
|
||||
"RECURRENT_TOPUP_SUCCESS": "✅ <b>自动扣款成功</b>\n\n余额已充值{amount},用于续订订阅。",
|
||||
"RECURRENT_TOPUP_FAILED": "❌ <b>自动扣款失败</b>\n\n无法从任何已保存的银行卡中扣除{amount}以续订订阅。\n\n请手动充值余额以避免服务中断。",
|
||||
"BACK": "⬅️返回",
|
||||
"BACK_TO_MAIN_MENU_BUTTON": "⬅️返回主菜单",
|
||||
"BACK_TO_MENU": "🏠返回主菜单",
|
||||
@@ -1127,6 +1135,9 @@
|
||||
"PAYMENT_METHOD_YOOKASSA_SBP_NAME": "🏦<b>SBP(YooKassa)</b>",
|
||||
"PAYMENT_METHOD_WATA_DESCRIPTION": "通过WATA",
|
||||
"PAYMENT_METHOD_WATA_NAME": "💳<b>银行卡(WATA)</b>",
|
||||
"PAYMENT_METHOD_RIOPAY_DESCRIPTION": "通过RioPay",
|
||||
"PAYMENT_METHOD_RIOPAY_NAME": "💳<b>银行卡(RioPay)</b>",
|
||||
"PAYMENT_RIOPAY": "💳 银行卡(RioPay)",
|
||||
"PAYMENT_HELEKET_MARKUP_LABEL": "服务商加价",
|
||||
"PAYMENT_HELEKET_DISCOUNT_LABEL": "服务商折扣",
|
||||
"PAYMENT_RETURN_HOME_BUTTON": "🏠返回首页",
|
||||
@@ -1243,6 +1254,7 @@
|
||||
"REFERRAL_RECENT_EARNINGS_ITEM": "•{reason}:<b>{amount}</b>(来自{referral_name})",
|
||||
"REFERRAL_REWARDS_HEADER": "🎁<b>奖励如何运作:</b>",
|
||||
"REFERRAL_REWARD_COMMISSION": "•每次推荐充值的佣金:<b>{percent}%</b>",
|
||||
"REFERRAL_REWARD_COMMISSION_LIMITED": "•前{max_payments}次推荐充值的佣金:<b>{percent}%</b>",
|
||||
"REFERRAL_REWARD_INVITER": "•推荐首次充值时您将获得:<b>{bonus}</b>",
|
||||
"REFERRAL_REWARD_NEW_USER": "•新用户首次充值<b>{minimum}</b>起将获得:<b>{bonus}</b>",
|
||||
"REFERRAL_SHARE_BUTTON": "📤分享",
|
||||
|
||||
@@ -398,7 +398,7 @@ class ChannelCheckerMiddleware(BaseMiddleware):
|
||||
# Notify user about deactivation
|
||||
try:
|
||||
normalized = _normalize_channels(channels)
|
||||
texts = get_texts(user.language if user.language else DEFAULT_LANGUAGE)
|
||||
texts = get_texts(user.language or DEFAULT_LANGUAGE)
|
||||
notification_text = texts.t(
|
||||
'SUBSCRIPTION_DEACTIVATED_CHANNEL_UNSUBSCRIBE',
|
||||
'🚫 Ваша подписка приостановлена, так как вы отписались от канала.\n\n'
|
||||
@@ -468,7 +468,7 @@ class ChannelCheckerMiddleware(BaseMiddleware):
|
||||
|
||||
# Notify user about reactivation
|
||||
try:
|
||||
texts = get_texts(user.language if user.language else DEFAULT_LANGUAGE)
|
||||
texts = get_texts(user.language or DEFAULT_LANGUAGE)
|
||||
notification_text = texts.t(
|
||||
'SUBSCRIPTION_REACTIVATED_CHANNEL_SUBSCRIBE',
|
||||
'✅ Ваша подписка восстановлена!\n\nСпасибо, что подписались на канал. VPN снова работает.',
|
||||
|
||||
@@ -401,7 +401,7 @@ class AdminNotificationService:
|
||||
period_days: int,
|
||||
was_trial_conversion: bool = False,
|
||||
amount_kopeks: int | None = None,
|
||||
purchase_type: str | None = None, # 'tariff_switch', 'renewal', None (auto)
|
||||
purchase_type: str | None = None, # 'first_purchase', 'renewal', 'tariff_switch', None (auto-detect)
|
||||
) -> bool:
|
||||
try:
|
||||
total_amount = (
|
||||
@@ -436,7 +436,10 @@ class AdminNotificationService:
|
||||
elif was_trial_conversion:
|
||||
event_title = '🔄 КОНВЕРСИЯ ИЗ ТРИАЛА'
|
||||
user_status = 'Конверсия'
|
||||
elif purchase_type == 'renewal' or user.has_had_paid_subscription:
|
||||
elif purchase_type == 'first_purchase':
|
||||
event_title = '💎 ПОКУПКА ПОДПИСКИ'
|
||||
user_status = 'Первая покупка'
|
||||
elif purchase_type == 'renewal' or (purchase_type is None and user.has_had_paid_subscription):
|
||||
event_title = '💎 ПРОДЛЕНИЕ ПОДПИСКИ'
|
||||
user_status = 'Продление'
|
||||
else:
|
||||
|
||||
@@ -347,7 +347,7 @@ class BackupService:
|
||||
with tempfile.TemporaryDirectory() as temp_dir:
|
||||
temp_path = Path(temp_dir)
|
||||
staging_dir = temp_path / 'backup'
|
||||
staging_dir.mkdir(parents=True, exist_ok=True)
|
||||
await asyncio.to_thread(lambda: staging_dir.mkdir(parents=True, exist_ok=True))
|
||||
|
||||
database_info = await self._dump_database(staging_dir, include_logs=include_logs)
|
||||
database_info.setdefault('tables_count', overview.get('tables_count', 0))
|
||||
@@ -376,10 +376,10 @@ class BackupService:
|
||||
|
||||
mode = 'w:gz' if compress else 'w'
|
||||
with tarfile.open(backup_path, mode) as tar:
|
||||
for item in staging_dir.iterdir():
|
||||
for item in await asyncio.to_thread(lambda: list(staging_dir.iterdir())):
|
||||
tar.add(item, arcname=item.name)
|
||||
|
||||
file_size = backup_path.stat().st_size
|
||||
file_size = (await asyncio.to_thread(backup_path.stat)).st_size
|
||||
|
||||
await self._cleanup_old_backups()
|
||||
|
||||
@@ -415,7 +415,7 @@ class BackupService:
|
||||
logger.info('📄 Начинаем восстановление из', backup_file_path=backup_file_path)
|
||||
|
||||
backup_path = Path(backup_file_path)
|
||||
if not backup_path.exists():
|
||||
if not await asyncio.to_thread(backup_path.exists):
|
||||
return False, f'❌ Файл бекапа не найден: {backup_file_path}'
|
||||
|
||||
if self._is_archive_backup(backup_path):
|
||||
@@ -473,7 +473,11 @@ class BackupService:
|
||||
if pg_dump_path:
|
||||
dump_path = staging_dir / 'database.sql'
|
||||
await self._dump_postgres(dump_path, pg_dump_path)
|
||||
size = dump_path.stat().st_size if dump_path.exists() else 0
|
||||
size = (
|
||||
(await asyncio.to_thread(dump_path.stat)).st_size
|
||||
if await asyncio.to_thread(dump_path.exists)
|
||||
else 0
|
||||
)
|
||||
return {
|
||||
'type': 'postgresql',
|
||||
'path': dump_path.name,
|
||||
@@ -488,7 +492,7 @@ class BackupService:
|
||||
|
||||
dump_path = staging_dir / 'database.sqlite'
|
||||
await self._dump_sqlite(dump_path)
|
||||
size = dump_path.stat().st_size if dump_path.exists() else 0
|
||||
size = (await asyncio.to_thread(dump_path.stat)).st_size if await asyncio.to_thread(dump_path.exists) else 0
|
||||
return {
|
||||
'type': 'sqlite',
|
||||
'path': dump_path.name,
|
||||
@@ -516,9 +520,9 @@ class BackupService:
|
||||
]
|
||||
|
||||
logger.info('📦 Экспорт PostgreSQL через pg_dump ...', pg_dump_path=pg_dump_path)
|
||||
dump_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
await asyncio.to_thread(lambda: dump_path.parent.mkdir(parents=True, exist_ok=True))
|
||||
|
||||
with dump_path.open('wb') as dump_file:
|
||||
with open(dump_path, 'wb') as dump_file:
|
||||
process = await asyncio.create_subprocess_exec(
|
||||
*command,
|
||||
stdout=dump_file,
|
||||
@@ -558,7 +562,7 @@ class BackupService:
|
||||
async with aiofiles.open(dump_path, 'w', encoding='utf-8') as dump_file:
|
||||
await dump_file.write(json_lib.dumps(dump_structure, ensure_ascii=False, indent=2))
|
||||
|
||||
size = dump_path.stat().st_size if dump_path.exists() else 0
|
||||
size = (await asyncio.to_thread(dump_path.stat)).st_size if await asyncio.to_thread(dump_path.exists) else 0
|
||||
|
||||
logger.info('✅ PostgreSQL экспортирован через ORM в JSON', dump_path=dump_path)
|
||||
|
||||
@@ -575,10 +579,10 @@ class BackupService:
|
||||
|
||||
async def _dump_sqlite(self, dump_path: Path):
|
||||
sqlite_path = Path(settings.SQLITE_PATH)
|
||||
if not sqlite_path.exists():
|
||||
if not await asyncio.to_thread(sqlite_path.exists):
|
||||
raise FileNotFoundError(f'SQLite база данных не найдена по пути {sqlite_path}')
|
||||
|
||||
dump_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
await asyncio.to_thread(lambda: dump_path.parent.mkdir(parents=True, exist_ok=True))
|
||||
await asyncio.to_thread(shutil.copy2, sqlite_path, dump_path)
|
||||
logger.info('✅ SQLite база данных скопирована', dump_path=dump_path)
|
||||
|
||||
@@ -662,11 +666,11 @@ class BackupService:
|
||||
async def _collect_files(self, staging_dir: Path, include_logs: bool) -> list[dict[str, Any]]:
|
||||
files_info: list[dict[str, Any]] = []
|
||||
files_dir = staging_dir / 'files'
|
||||
files_dir.mkdir(parents=True, exist_ok=True)
|
||||
await asyncio.to_thread(lambda: files_dir.mkdir(parents=True, exist_ok=True))
|
||||
|
||||
if include_logs and settings.LOG_FILE:
|
||||
log_path = Path(settings.LOG_FILE)
|
||||
if log_path.exists():
|
||||
if await asyncio.to_thread(log_path.exists):
|
||||
dest = files_dir / log_path.name
|
||||
await asyncio.to_thread(shutil.copy2, log_path, dest)
|
||||
files_info.append(
|
||||
@@ -676,8 +680,8 @@ class BackupService:
|
||||
}
|
||||
)
|
||||
|
||||
if not files_info and files_dir.exists():
|
||||
files_dir.rmdir()
|
||||
if not files_info and await asyncio.to_thread(files_dir.exists):
|
||||
await asyncio.to_thread(files_dir.rmdir)
|
||||
|
||||
return files_info
|
||||
|
||||
@@ -688,7 +692,7 @@ class BackupService:
|
||||
'items': 0,
|
||||
}
|
||||
|
||||
if not self.data_dir.exists():
|
||||
if not await asyncio.to_thread(self.data_dir.exists):
|
||||
return snapshot_info
|
||||
|
||||
counter = {'items': 0}
|
||||
@@ -732,7 +736,7 @@ class BackupService:
|
||||
tar.extractall(temp_path, filter='data')
|
||||
|
||||
metadata_path = temp_path / 'metadata.json'
|
||||
if not metadata_path.exists():
|
||||
if not await asyncio.to_thread(metadata_path.exists):
|
||||
return False, '❌ Метаданные бекапа отсутствуют'
|
||||
|
||||
async with aiofiles.open(metadata_path, encoding='utf-8') as meta_file:
|
||||
@@ -758,7 +762,7 @@ class BackupService:
|
||||
await self._restore_sqlite(dump_file, clear_existing)
|
||||
|
||||
data_dir = temp_path / 'data'
|
||||
if data_dir.exists():
|
||||
if await asyncio.to_thread(data_dir.exists):
|
||||
await self._restore_data_snapshot(data_dir, clear_existing)
|
||||
|
||||
if files_info:
|
||||
@@ -775,7 +779,7 @@ class BackupService:
|
||||
return True, message
|
||||
|
||||
async def _restore_postgres(self, dump_path: Path, clear_existing: bool):
|
||||
if not dump_path.exists():
|
||||
if not await asyncio.to_thread(dump_path.exists):
|
||||
raise FileNotFoundError(f'Dump PostgreSQL не найден: {dump_path}')
|
||||
|
||||
psql_path = self._resolve_command_path('psql', 'PSQL_PATH')
|
||||
@@ -833,7 +837,7 @@ class BackupService:
|
||||
logger.info('✅ PostgreSQL восстановлен', dump_path=dump_path)
|
||||
|
||||
async def _restore_postgres_json(self, dump_path: Path, clear_existing: bool):
|
||||
if not dump_path.exists():
|
||||
if not await asyncio.to_thread(dump_path.exists):
|
||||
raise FileNotFoundError(f'JSON дамп PostgreSQL не найден: {dump_path}')
|
||||
|
||||
async with aiofiles.open(dump_path, encoding='utf-8') as dump_file:
|
||||
@@ -853,20 +857,20 @@ class BackupService:
|
||||
logger.info('✅ PostgreSQL восстановлен из ORM JSON', dump_path=dump_path)
|
||||
|
||||
async def _restore_sqlite(self, dump_path: Path, clear_existing: bool):
|
||||
if not dump_path.exists():
|
||||
if not await asyncio.to_thread(dump_path.exists):
|
||||
raise FileNotFoundError(f'SQLite файл не найден: {dump_path}')
|
||||
|
||||
target_path = Path(settings.SQLITE_PATH)
|
||||
target_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
await asyncio.to_thread(lambda: target_path.parent.mkdir(parents=True, exist_ok=True))
|
||||
|
||||
if clear_existing and target_path.exists():
|
||||
target_path.unlink()
|
||||
if clear_existing and await asyncio.to_thread(target_path.exists):
|
||||
await asyncio.to_thread(target_path.unlink)
|
||||
|
||||
await asyncio.to_thread(shutil.copy2, dump_path, target_path)
|
||||
logger.info('✅ SQLite база восстановлена', target_path=target_path)
|
||||
|
||||
async def _restore_data_snapshot(self, source_dir: Path, clear_existing: bool):
|
||||
if not source_dir.exists():
|
||||
if not await asyncio.to_thread(source_dir.exists):
|
||||
return
|
||||
|
||||
def _restore():
|
||||
@@ -891,7 +895,7 @@ class BackupService:
|
||||
logger.info('📁 Снимок директории data восстановлен')
|
||||
|
||||
async def _restore_files(self, files_info: list[dict[str, Any]], temp_path: Path):
|
||||
allowed_base = self.data_dir.resolve()
|
||||
allowed_base = await asyncio.to_thread(self.data_dir.resolve)
|
||||
|
||||
for file_info in files_info:
|
||||
relative_path = file_info.get('relative_path')
|
||||
@@ -899,21 +903,22 @@ class BackupService:
|
||||
if not relative_path or not target_path:
|
||||
continue
|
||||
|
||||
target_resolved = target_path.resolve()
|
||||
target_resolved = await asyncio.to_thread(target_path.resolve)
|
||||
if not str(target_resolved).startswith(str(allowed_base) + os.sep) and target_resolved != allowed_base:
|
||||
logger.warning('Заблокирована запись за пределами data_dir', target_path=target_path)
|
||||
continue
|
||||
|
||||
source_file = (temp_path / relative_path).resolve()
|
||||
if not str(source_file).startswith(str(temp_path.resolve()) + os.sep):
|
||||
source_file = await asyncio.to_thread((temp_path / relative_path).resolve)
|
||||
temp_path_resolved = await asyncio.to_thread(temp_path.resolve)
|
||||
if not str(source_file).startswith(str(temp_path_resolved) + os.sep):
|
||||
logger.warning('Path traversal в relative_path', relative_path=relative_path)
|
||||
continue
|
||||
|
||||
if not source_file.exists():
|
||||
if not await asyncio.to_thread(source_file.exists):
|
||||
logger.warning('Файл отсутствует в архиве', relative_path=relative_path)
|
||||
continue
|
||||
|
||||
target_resolved.parent.mkdir(parents=True, exist_ok=True)
|
||||
await asyncio.to_thread(lambda: target_resolved.parent.mkdir(parents=True, exist_ok=True))
|
||||
await asyncio.to_thread(shutil.copy2, source_file, target_resolved)
|
||||
logger.info('📁 Файл восстановлен', target_resolved=target_resolved)
|
||||
|
||||
@@ -1573,8 +1578,10 @@ class BackupService:
|
||||
backups = []
|
||||
|
||||
try:
|
||||
for backup_file in sorted(self.backup_dir.glob('backup_*'), reverse=True):
|
||||
if not backup_file.is_file():
|
||||
for backup_file in sorted(
|
||||
await asyncio.to_thread(lambda: list(self.backup_dir.glob('backup_*'))), reverse=True
|
||||
):
|
||||
if not await asyncio.to_thread(backup_file.is_file):
|
||||
continue
|
||||
|
||||
try:
|
||||
@@ -1598,7 +1605,7 @@ class BackupService:
|
||||
backup_structure = json_lib.load(f)
|
||||
metadata = backup_structure.get('metadata', {})
|
||||
|
||||
file_stats = backup_file.stat()
|
||||
file_stats = await asyncio.to_thread(backup_file.stat)
|
||||
|
||||
backup_info = {
|
||||
'filename': backup_file.name,
|
||||
@@ -1626,7 +1633,7 @@ class BackupService:
|
||||
|
||||
except Exception as e:
|
||||
logger.error('Ошибка чтения метаданных', backup_file=backup_file, error=e)
|
||||
file_stats = backup_file.stat()
|
||||
file_stats = await asyncio.to_thread(backup_file.stat)
|
||||
backups.append(
|
||||
{
|
||||
'filename': backup_file.name,
|
||||
@@ -1651,14 +1658,15 @@ class BackupService:
|
||||
|
||||
async def delete_backup(self, backup_filename: str) -> tuple[bool, str]:
|
||||
try:
|
||||
backup_path = (self.backup_dir / backup_filename).resolve()
|
||||
if not str(backup_path).startswith(str(self.backup_dir.resolve()) + os.sep):
|
||||
backup_path = await asyncio.to_thread((self.backup_dir / backup_filename).resolve)
|
||||
backup_dir_resolved = await asyncio.to_thread(self.backup_dir.resolve)
|
||||
if not str(backup_path).startswith(str(backup_dir_resolved) + os.sep):
|
||||
return False, '❌ Недопустимое имя файла бекапа'
|
||||
|
||||
if not backup_path.is_file():
|
||||
if not await asyncio.to_thread(backup_path.is_file):
|
||||
return False, f'❌ Файл бекапа не найден: {backup_filename}'
|
||||
|
||||
backup_path.unlink()
|
||||
await asyncio.to_thread(backup_path.unlink)
|
||||
message = f'✅ Бекап {backup_filename} удален'
|
||||
logger.info(message)
|
||||
|
||||
@@ -1826,9 +1834,9 @@ class BackupService:
|
||||
await self.bot.send_document(**send_kwargs)
|
||||
logger.info('Бекап отправлен в чат', chat_id=chat_id)
|
||||
|
||||
if temp_zip_path and Path(temp_zip_path).exists():
|
||||
if temp_zip_path and await asyncio.to_thread(Path(temp_zip_path).exists):
|
||||
try:
|
||||
Path(temp_zip_path).unlink()
|
||||
await asyncio.to_thread(Path(temp_zip_path).unlink)
|
||||
except Exception as cleanup_error:
|
||||
logger.warning('Не удалось удалить временный архив', cleanup_error=cleanup_error)
|
||||
|
||||
@@ -1838,7 +1846,7 @@ class BackupService:
|
||||
async def _create_password_protected_archive(self, file_path: str, password: str) -> str | None:
|
||||
try:
|
||||
source_path = Path(file_path)
|
||||
if not source_path.exists():
|
||||
if not await asyncio.to_thread(source_path.exists):
|
||||
logger.error('Исходный файл бекапа не найден', file_path=file_path)
|
||||
return None
|
||||
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
"""Enum classes for contest system."""
|
||||
|
||||
from enum import Enum
|
||||
from enum import StrEnum
|
||||
|
||||
|
||||
class GameType(str, Enum):
|
||||
class GameType(StrEnum):
|
||||
"""Types of daily contest games."""
|
||||
|
||||
QUEST_BUTTONS = 'quest_buttons'
|
||||
@@ -30,14 +30,14 @@ class GameType(str, Enum):
|
||||
}
|
||||
|
||||
|
||||
class RoundStatus(str, Enum):
|
||||
class RoundStatus(StrEnum):
|
||||
"""Contest round status."""
|
||||
|
||||
ACTIVE = 'active'
|
||||
FINISHED = 'finished'
|
||||
|
||||
|
||||
class PrizeType(str, Enum):
|
||||
class PrizeType(StrEnum):
|
||||
"""Types of prizes for contests."""
|
||||
|
||||
DAYS = 'days'
|
||||
|
||||
@@ -17,7 +17,17 @@ from app.config import settings
|
||||
from app.database.crud.landing import create_guest_purchase
|
||||
from app.database.crud.subscription import create_paid_subscription, get_subscription_by_user_id, replace_subscription
|
||||
from app.database.crud.tariff import get_tariff_by_id
|
||||
from app.database.models import GuestPurchase, GuestPurchaseStatus, LandingPage, Tariff, User
|
||||
from app.database.crud.transaction import create_transaction
|
||||
from app.database.crud.user import _get_or_create_default_promo_group
|
||||
from app.database.models import (
|
||||
GuestPurchase,
|
||||
GuestPurchaseStatus,
|
||||
LandingPage,
|
||||
PaymentMethod,
|
||||
Tariff,
|
||||
TransactionType,
|
||||
User,
|
||||
)
|
||||
from app.services.subscription_service import SubscriptionService
|
||||
|
||||
|
||||
@@ -242,7 +252,7 @@ async def fulfill_purchase(
|
||||
# Active subscription or gift with any existing subscription — hold for manual activation
|
||||
purchase.status = GuestPurchaseStatus.PENDING_ACTIVATION.value
|
||||
purchase.user_id = user.id
|
||||
if recipient_type == 'email' and not purchase.is_gift:
|
||||
if recipient_type == 'email' and not purchase.is_gift and is_new_account:
|
||||
purchase.auto_login_token = create_auto_login_token(user.id)
|
||||
await db.commit()
|
||||
await db.refresh(purchase, attribute_names=['landing', 'user'])
|
||||
@@ -310,12 +320,28 @@ async def fulfill_purchase(
|
||||
purchase.status = GuestPurchaseStatus.DELIVERED.value
|
||||
purchase.user_id = user.id
|
||||
purchase.delivered_at = datetime.now(UTC)
|
||||
if recipient_type == 'email' and not purchase.is_gift:
|
||||
if recipient_type == 'email' and not purchase.is_gift and is_new_account:
|
||||
purchase.auto_login_token = create_auto_login_token(user.id)
|
||||
|
||||
await db.commit()
|
||||
await db.refresh(purchase, attribute_names=['landing', 'user'])
|
||||
|
||||
# Create transaction so promo group auto-assignment and contest tracking work
|
||||
try:
|
||||
payment_method_enum = _resolve_payment_method(purchase.payment_method)
|
||||
await create_transaction(
|
||||
db=db,
|
||||
user_id=user.id,
|
||||
type=TransactionType.SUBSCRIPTION_PAYMENT,
|
||||
amount_kopeks=purchase.amount_kopeks,
|
||||
description=f'Покупка подписки через лендинг ({notification_tariff_name}, {purchase.period_days} дн.)',
|
||||
payment_method=payment_method_enum,
|
||||
external_id=purchase.payment_id,
|
||||
is_completed=True,
|
||||
)
|
||||
except Exception:
|
||||
logger.exception('Failed to create transaction for guest purchase', purchase_id=purchase.id)
|
||||
|
||||
try:
|
||||
await send_guest_notification(
|
||||
purchase,
|
||||
@@ -357,6 +383,26 @@ async def fulfill_purchase(
|
||||
return purchase
|
||||
|
||||
|
||||
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.)
|
||||
try:
|
||||
return PaymentMethod(method_str)
|
||||
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
|
||||
|
||||
|
||||
def _mask_email(email: str) -> str:
|
||||
"""Mask email for logging: 'user@example.com' -> 'u***@e***.com'."""
|
||||
if not email:
|
||||
@@ -399,8 +445,8 @@ async def _find_or_create_user(
|
||||
user = result.scalars().first()
|
||||
if user:
|
||||
is_new_account = False
|
||||
# Existing user WITHOUT password — generate one and set up cabinet access
|
||||
if not user.password_hash:
|
||||
# User without cabinet access — generate credentials
|
||||
plain_password = secrets.token_urlsafe(12)
|
||||
user.password_hash = hash_password(plain_password)
|
||||
if purchase:
|
||||
@@ -410,16 +456,22 @@ async def _find_or_create_user(
|
||||
if not user.email_verified:
|
||||
user.email_verified = True
|
||||
user.email_verified_at = datetime.now(UTC)
|
||||
# Ensure default promo group
|
||||
if not user.promo_group_id:
|
||||
default_group = await _get_or_create_default_promo_group(db)
|
||||
user.promo_group_id = default_group.id
|
||||
return user, is_new_account
|
||||
|
||||
# Create new email user with verified cabinet account
|
||||
plain_password = secrets.token_urlsafe(12)
|
||||
default_group = await _get_or_create_default_promo_group(db)
|
||||
user = User(
|
||||
auth_type='email',
|
||||
email=contact_value,
|
||||
email_verified=True,
|
||||
email_verified_at=datetime.now(UTC),
|
||||
password_hash=hash_password(plain_password),
|
||||
promo_group_id=default_group.id,
|
||||
)
|
||||
if purchase:
|
||||
purchase.cabinet_password = plain_password
|
||||
@@ -431,7 +483,7 @@ async def _find_or_create_user(
|
||||
result = await db.execute(select(User).where(User.email == contact_value))
|
||||
user = result.scalars().first()
|
||||
if user:
|
||||
# Clear stale password from failed insert, then check if re-fetched user needs one
|
||||
# Race condition — user was created concurrently
|
||||
if purchase:
|
||||
purchase.cabinet_password = None
|
||||
is_new_account = False
|
||||
@@ -444,6 +496,9 @@ async def _find_or_create_user(
|
||||
if not user.email_verified:
|
||||
user.email_verified = True
|
||||
user.email_verified_at = datetime.now(UTC)
|
||||
if not user.promo_group_id:
|
||||
default_group = await _get_or_create_default_promo_group(db)
|
||||
user.promo_group_id = default_group.id
|
||||
return user, is_new_account
|
||||
raise
|
||||
logger.info(
|
||||
@@ -508,13 +563,19 @@ async def _find_or_create_user(
|
||||
resolved_telegram_id=resolved_telegram_id,
|
||||
)
|
||||
await db.refresh(user)
|
||||
# Ensure default promo group
|
||||
if not user.promo_group_id:
|
||||
default_group = await _get_or_create_default_promo_group(db)
|
||||
user.promo_group_id = default_group.id
|
||||
return user, False
|
||||
|
||||
# Create new telegram user
|
||||
default_group = await _get_or_create_default_promo_group(db)
|
||||
user = User(
|
||||
auth_type='telegram',
|
||||
username=username,
|
||||
telegram_id=resolved_telegram_id,
|
||||
promo_group_id=default_group.id,
|
||||
)
|
||||
try:
|
||||
async with db.begin_nested():
|
||||
@@ -525,10 +586,16 @@ async def _find_or_create_user(
|
||||
result = await db.execute(select(User).where(User.telegram_id == resolved_telegram_id))
|
||||
user = result.scalars().first()
|
||||
if user:
|
||||
if not user.promo_group_id:
|
||||
default_group = await _get_or_create_default_promo_group(db)
|
||||
user.promo_group_id = default_group.id
|
||||
return user, False
|
||||
result = await db.execute(select(User).where(func.lower(User.username) == normalized))
|
||||
user = result.scalars().first()
|
||||
if user:
|
||||
if not user.promo_group_id:
|
||||
default_group = await _get_or_create_default_promo_group(db)
|
||||
user.promo_group_id = default_group.id
|
||||
return user, False
|
||||
raise
|
||||
logger.info(
|
||||
@@ -831,10 +898,9 @@ async def activate_purchase(db: AsyncSession, purchase_token: str, *, skip_notif
|
||||
connected_squads=tariff.allowed_squads or [],
|
||||
is_trial=False,
|
||||
update_server_counters=True,
|
||||
commit=False,
|
||||
)
|
||||
subscription.tariff_id = tariff.id
|
||||
await db.commit()
|
||||
await db.refresh(subscription, ['tariff'])
|
||||
else:
|
||||
subscription = await create_paid_subscription(
|
||||
db=db,
|
||||
@@ -845,6 +911,7 @@ async def activate_purchase(db: AsyncSession, purchase_token: str, *, skip_notif
|
||||
connected_squads=tariff.allowed_squads or [],
|
||||
tariff_id=tariff.id,
|
||||
update_server_counters=True,
|
||||
commit=False,
|
||||
)
|
||||
|
||||
await subscription_service.create_remnawave_user(db, subscription)
|
||||
@@ -854,11 +921,29 @@ async def activate_purchase(db: AsyncSession, purchase_token: str, *, skip_notif
|
||||
purchase.subscription_crypto_link = subscription.subscription_crypto_link
|
||||
purchase.status = GuestPurchaseStatus.DELIVERED.value
|
||||
purchase.delivered_at = datetime.now(UTC)
|
||||
if user.auth_type == 'email' and not purchase.is_gift:
|
||||
if user.auth_type == 'email' and not purchase.is_gift and is_new_account:
|
||||
purchase.auto_login_token = create_auto_login_token(user.id)
|
||||
|
||||
# Single atomic commit: subscription + purchase status + user changes
|
||||
await db.commit()
|
||||
await db.refresh(purchase, attribute_names=['landing', 'user'])
|
||||
|
||||
# Create transaction so promo group auto-assignment and contest tracking work
|
||||
try:
|
||||
payment_method_enum = _resolve_payment_method(purchase.payment_method)
|
||||
await create_transaction(
|
||||
db=db,
|
||||
user_id=user.id,
|
||||
type=TransactionType.SUBSCRIPTION_PAYMENT,
|
||||
amount_kopeks=purchase.amount_kopeks,
|
||||
description=f'Покупка подписки через лендинг ({notification_tariff_name}, {purchase.period_days} дн.)',
|
||||
payment_method=payment_method_enum,
|
||||
external_id=purchase.payment_id,
|
||||
is_completed=True,
|
||||
)
|
||||
except Exception:
|
||||
logger.exception('Failed to create transaction for activated purchase', purchase_id=purchase.id)
|
||||
|
||||
if not skip_notification:
|
||||
try:
|
||||
await send_guest_notification(
|
||||
@@ -923,6 +1008,8 @@ async def retry_stuck_paid_purchases(
|
||||
GuestPurchase.status == GuestPurchaseStatus.PAID.value,
|
||||
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
|
||||
~(GuestPurchase.is_gift.is_(True) & GuestPurchase.gift_recipient_type.is_(None)),
|
||||
)
|
||||
.order_by(GuestPurchase.paid_at.asc().nulls_first())
|
||||
.limit(limit)
|
||||
@@ -943,3 +1030,49 @@ async def retry_stuck_paid_purchases(
|
||||
logger.exception('Failed to retry stuck purchase', token_prefix=token[:5])
|
||||
|
||||
return retried
|
||||
|
||||
|
||||
async def retry_stuck_pending_activation(
|
||||
db: AsyncSession,
|
||||
stale_minutes: int = 10,
|
||||
limit: int = 10,
|
||||
max_age_hours: int = 24,
|
||||
) -> 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.
|
||||
"""
|
||||
from app.database.database import AsyncSessionLocal
|
||||
|
||||
cutoff = datetime.now(UTC) - timedelta(minutes=stale_minutes)
|
||||
max_age = datetime.now(UTC) - timedelta(hours=max_age_hours)
|
||||
|
||||
result = await db.execute(
|
||||
select(GuestPurchase.token)
|
||||
.where(
|
||||
GuestPurchase.status == GuestPurchaseStatus.PENDING_ACTIVATION.value,
|
||||
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),
|
||||
)
|
||||
.order_by(GuestPurchase.paid_at.asc().nulls_first())
|
||||
.limit(limit)
|
||||
)
|
||||
tokens = result.scalars().all()
|
||||
|
||||
if not tokens:
|
||||
return 0
|
||||
|
||||
retried = 0
|
||||
for token in tokens:
|
||||
try:
|
||||
async with AsyncSessionLocal() as retry_db:
|
||||
await activate_purchase(retry_db, token)
|
||||
retried += 1
|
||||
logger.info('Retried stuck pending_activation successfully', token_prefix=token[:5])
|
||||
except Exception:
|
||||
logger.exception('Failed to retry stuck pending_activation', token_prefix=token[:5])
|
||||
|
||||
return retried
|
||||
|
||||
@@ -78,8 +78,8 @@ class LogRotationService:
|
||||
|
||||
async def initialize(self) -> None:
|
||||
"""Создать необходимые директории."""
|
||||
self.current_dir.mkdir(parents=True, exist_ok=True)
|
||||
self.archive_dir.mkdir(parents=True, exist_ok=True)
|
||||
await asyncio.to_thread(lambda: self.current_dir.mkdir(parents=True, exist_ok=True))
|
||||
await asyncio.to_thread(lambda: self.archive_dir.mkdir(parents=True, exist_ok=True))
|
||||
|
||||
async def start(self) -> None:
|
||||
"""Запустить сервис ротации."""
|
||||
@@ -170,7 +170,7 @@ class LogRotationService:
|
||||
# Собираем файлы для архивации
|
||||
files_to_archive: list[tuple[Path, str]] = []
|
||||
for name, log_path in self.log_files.items():
|
||||
if log_path.exists() and log_path.stat().st_size > 0:
|
||||
if await asyncio.to_thread(log_path.exists) and (await asyncio.to_thread(log_path.stat)).st_size > 0:
|
||||
files_to_archive.append((log_path, f'{name}.log'))
|
||||
|
||||
if not files_to_archive:
|
||||
@@ -184,7 +184,7 @@ class LogRotationService:
|
||||
if archive_path:
|
||||
# Очищаем текущие лог-файлы
|
||||
for log_path, _ in files_to_archive:
|
||||
log_path.write_text('')
|
||||
await asyncio.to_thread(log_path.write_text, '')
|
||||
|
||||
# Очистка старых архивов
|
||||
await self._cleanup_old_archives()
|
||||
@@ -247,12 +247,12 @@ class LogRotationService:
|
||||
keep_days = settings.LOG_ROTATION_KEEP_DAYS
|
||||
cutoff_date = datetime.now(get_local_timezone()) - timedelta(days=keep_days)
|
||||
|
||||
if not self.archive_dir.exists():
|
||||
if not await asyncio.to_thread(self.archive_dir.exists):
|
||||
return
|
||||
|
||||
# Ищем файлы вида logs_YYYY-MM-DD.tar.gz или logs_YYYY-MM-DD.tar
|
||||
for archive_file in self.archive_dir.iterdir():
|
||||
if not archive_file.is_file():
|
||||
for archive_file in await asyncio.to_thread(lambda: list(self.archive_dir.iterdir())):
|
||||
if not await asyncio.to_thread(archive_file.is_file):
|
||||
continue
|
||||
|
||||
# Извлекаем дату из имени файла logs_YYYY-MM-DD.tar.gz
|
||||
@@ -267,7 +267,7 @@ class LogRotationService:
|
||||
file_date = file_date.replace(tzinfo=get_local_timezone())
|
||||
|
||||
if file_date < cutoff_date:
|
||||
archive_file.unlink()
|
||||
await asyncio.to_thread(archive_file.unlink)
|
||||
logger.info('Удален старый архив логов', archive_file_name=archive_file.name)
|
||||
except ValueError:
|
||||
# Пропускаем файлы с некорректным форматом имени
|
||||
@@ -287,7 +287,7 @@ class LogRotationService:
|
||||
topic_id = settings.get_log_rotation_topic_id()
|
||||
|
||||
try:
|
||||
file_size_kb = archive_path.stat().st_size / 1024
|
||||
file_size_kb = (await asyncio.to_thread(archive_path.stat)).st_size / 1024
|
||||
caption = (
|
||||
f'<b>Логи бота</b>\n'
|
||||
f'Дата: {date_str}\n'
|
||||
|
||||
@@ -111,7 +111,11 @@ class MonitoringService:
|
||||
logger.debug('Пропуск уведомления: пользователь недоступен', user_id=user.id, status=user.status)
|
||||
return None
|
||||
|
||||
if settings.ENABLE_LOGO_MODE and LOGO_PATH.exists() and not caption_exceeds_telegram_limit(text):
|
||||
if (
|
||||
settings.ENABLE_LOGO_MODE
|
||||
and await asyncio.to_thread(LOGO_PATH.exists)
|
||||
and not caption_exceeds_telegram_limit(text)
|
||||
):
|
||||
try:
|
||||
from app.utils.message_patch import _cache_logo_file_id, get_logo_media
|
||||
|
||||
@@ -221,6 +225,18 @@ class MonitoringService:
|
||||
# экспайрятся до того, как autopay успеет их продлить
|
||||
if settings.ENABLE_AUTOPAY:
|
||||
await self._process_autopayments(db)
|
||||
# Рекуррентные автоплатежи: пополнение баланса с сохранённой карты
|
||||
if settings.YOOKASSA_RECURRENT_ENABLED:
|
||||
try:
|
||||
from app.services.recurrent_payment_service import process_recurrent_payments
|
||||
|
||||
await process_recurrent_payments(db=db, bot=self.bot)
|
||||
except Exception as recurrent_error:
|
||||
logger.error(
|
||||
'Ошибка рекуррентных автоплатежей',
|
||||
error=recurrent_error,
|
||||
exc_info=True,
|
||||
)
|
||||
await self._check_expired_subscriptions(db)
|
||||
await self._check_expiring_subscriptions(db)
|
||||
await self._check_trial_expiring_soon(db)
|
||||
@@ -410,6 +426,15 @@ class MonitoringService:
|
||||
expiring_subscriptions = await self._get_expiring_paid_subscriptions(db, days)
|
||||
sent_count = 0
|
||||
|
||||
# Batch-запрос: собираем user_id с autopay и проверяем наличие карт одним запросом
|
||||
users_with_cards: set[int] = set()
|
||||
if settings.ENABLE_AUTOPAY and settings.YOOKASSA_RECURRENT_ENABLED:
|
||||
autopay_user_ids = [s.user_id for s in expiring_subscriptions if s.autopay_enabled]
|
||||
if autopay_user_ids:
|
||||
from app.database.crud.saved_payment_method import get_user_ids_with_active_payment_methods
|
||||
|
||||
users_with_cards = await get_user_ids_with_active_payment_methods(db, autopay_user_ids)
|
||||
|
||||
for subscription in expiring_subscriptions:
|
||||
user = await get_user_by_id(db, subscription.user_id)
|
||||
if not user:
|
||||
@@ -430,6 +455,8 @@ class MonitoringService:
|
||||
)
|
||||
continue
|
||||
|
||||
has_saved_card = subscription.autopay_enabled and user.id in users_with_cards
|
||||
|
||||
should_send = True
|
||||
for other_days in warning_days:
|
||||
if other_days < days:
|
||||
@@ -466,7 +493,9 @@ class MonitoringService:
|
||||
continue
|
||||
|
||||
if self.bot:
|
||||
success = await self._send_subscription_expiring_notification(user, subscription, days)
|
||||
success = await self._send_subscription_expiring_notification(
|
||||
user, subscription, days, has_saved_card=has_saved_card
|
||||
)
|
||||
if success:
|
||||
await record_notification(db, user.id, subscription.id, 'expiring', days)
|
||||
all_processed_users.add(user_key)
|
||||
@@ -1260,7 +1289,9 @@ class MonitoringService:
|
||||
)
|
||||
return False
|
||||
|
||||
async def _send_subscription_expiring_notification(self, user: User, subscription: Subscription, days: int) -> bool:
|
||||
async def _send_subscription_expiring_notification(
|
||||
self, user: User, subscription: Subscription, days: int, *, has_saved_card: bool = False
|
||||
) -> bool:
|
||||
try:
|
||||
from app.utils.formatters import format_days_declension
|
||||
|
||||
@@ -1268,27 +1299,56 @@ class MonitoringService:
|
||||
days_text = format_days_declension(days, user.language)
|
||||
|
||||
if settings.ENABLE_AUTOPAY:
|
||||
if subscription.autopay_enabled:
|
||||
autopay_status = '✅ Включен - подписка продлится автоматически'
|
||||
action_text = (
|
||||
f'💰 Убедитесь, что на балансе достаточно средств: {texts.format_price(user.balance_kopeks)}'
|
||||
if subscription.autopay_enabled and has_saved_card:
|
||||
autopay_status = texts.t(
|
||||
'AUTOPAY_STATUS_CARD_ACTIVE',
|
||||
'✅ Включен — будет автоматическое списание с карты',
|
||||
)
|
||||
action_text = texts.t(
|
||||
'AUTOPAY_ACTION_CHECK_BALANCE',
|
||||
'💰 Убедитесь, что на балансе достаточно средств: {balance}',
|
||||
).format(balance=texts.format_price(user.balance_kopeks))
|
||||
elif subscription.autopay_enabled:
|
||||
autopay_status = texts.t(
|
||||
'AUTOPAY_STATUS_NO_CARD',
|
||||
'✅ Включен — подписка продлится автоматически',
|
||||
)
|
||||
action_text = texts.t(
|
||||
'AUTOPAY_ACTION_CHECK_BALANCE',
|
||||
'💰 Убедитесь, что на балансе достаточно средств: {balance}',
|
||||
).format(balance=texts.format_price(user.balance_kopeks))
|
||||
else:
|
||||
autopay_status = '❌ Отключен - не забудьте продлить вручную!'
|
||||
action_text = '💡 Включите автоплатеж или продлите подписку вручную'
|
||||
autopay_status = texts.t(
|
||||
'AUTOPAY_STATUS_OFF',
|
||||
'❌ Отключен — не забудьте продлить вручную!',
|
||||
)
|
||||
action_text = texts.t(
|
||||
'AUTOPAY_ACTION_ENABLE',
|
||||
'💡 Включите автоплатеж или продлите подписку вручную',
|
||||
)
|
||||
else:
|
||||
autopay_status = '❌ Отключен - не забудьте продлить вручную!'
|
||||
action_text = '💡 Продлите подписку вручную'
|
||||
autopay_status = texts.t(
|
||||
'AUTOPAY_STATUS_OFF',
|
||||
'❌ Отключен — не забудьте продлить вручную!',
|
||||
)
|
||||
action_text = texts.t(
|
||||
'AUTOPAY_ACTION_RENEW',
|
||||
'💡 Продлите подписку вручную',
|
||||
)
|
||||
|
||||
message = f"""
|
||||
⚠️ <b>Подписка истекает через {days_text}!</b>
|
||||
|
||||
Ваша платная подписка истекает {format_local_datetime(subscription.end_date, '%d.%m.%Y %H:%M')}.
|
||||
|
||||
💳 <b>Автоплатеж:</b> {autopay_status}
|
||||
|
||||
{action_text}
|
||||
"""
|
||||
end_date = format_local_datetime(subscription.end_date, '%d.%m.%Y %H:%M')
|
||||
message = texts.t(
|
||||
'SUBSCRIPTION_EXPIRING_PAID',
|
||||
'\n⚠️ <b>Подписка истекает через {days_text}!</b>\n\n'
|
||||
'Ваша платная подписка истекает {end_date}.\n\n'
|
||||
'💳 <b>Автоплатеж:</b> {autopay_status}\n\n'
|
||||
'{action_text}\n',
|
||||
).format(
|
||||
days_text=days_text,
|
||||
end_date=end_date,
|
||||
autopay_status=autopay_status,
|
||||
action_text=action_text,
|
||||
)
|
||||
|
||||
from aiogram.types import InlineKeyboardMarkup
|
||||
|
||||
@@ -1682,11 +1742,15 @@ class MonitoringService:
|
||||
|
||||
async def _retry_stuck_guest_purchases(self, db: AsyncSession):
|
||||
try:
|
||||
from app.services.guest_purchase_service import retry_stuck_paid_purchases
|
||||
from app.services.guest_purchase_service import retry_stuck_paid_purchases, retry_stuck_pending_activation
|
||||
|
||||
retried = await retry_stuck_paid_purchases(db, stale_minutes=5, limit=10)
|
||||
if retried:
|
||||
logger.info('Retried stuck guest purchases', retried=retried)
|
||||
|
||||
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)
|
||||
|
||||
|
||||
@@ -9,6 +9,7 @@ import structlog
|
||||
from sqlalchemy import and_, case, desc, func, select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.database.crud.transaction import REAL_PAYMENT_METHODS
|
||||
from app.database.models import (
|
||||
AdvertisingCampaignRegistration,
|
||||
ReferralEarning,
|
||||
@@ -938,11 +939,18 @@ class PartnerStatsService:
|
||||
registrations_dict = {str(row.date): int(row.count) for row in registrations_by_day.all()}
|
||||
|
||||
# --- Daily revenue (DAILY_STATS_DAYS days) ---
|
||||
# Revenue = deposits (positive) + abs(subscription_payments) (stored negative)
|
||||
# Revenue = real deposits (positive) + abs(subscription_payments) (stored negative)
|
||||
# Exclude promo/bonus deposits (payment_method IS NULL) from revenue
|
||||
revenue_amount_expr = func.coalesce(
|
||||
func.sum(
|
||||
case(
|
||||
(Transaction.type == TransactionType.DEPOSIT.value, Transaction.amount_kopeks),
|
||||
(
|
||||
and_(
|
||||
Transaction.type == TransactionType.DEPOSIT.value,
|
||||
Transaction.payment_method.in_(REAL_PAYMENT_METHODS),
|
||||
),
|
||||
Transaction.amount_kopeks,
|
||||
),
|
||||
(
|
||||
Transaction.type == TransactionType.SUBSCRIPTION_PAYMENT.value,
|
||||
func.abs(Transaction.amount_kopeks),
|
||||
@@ -1070,7 +1078,13 @@ class PartnerStatsService:
|
||||
func.coalesce(
|
||||
func.sum(
|
||||
case(
|
||||
(Transaction.type == TransactionType.DEPOSIT.value, Transaction.amount_kopeks),
|
||||
(
|
||||
and_(
|
||||
Transaction.type == TransactionType.DEPOSIT.value,
|
||||
Transaction.payment_method.in_(REAL_PAYMENT_METHODS),
|
||||
),
|
||||
Transaction.amount_kopeks,
|
||||
),
|
||||
else_=0,
|
||||
)
|
||||
),
|
||||
@@ -1117,7 +1131,13 @@ class PartnerStatsService:
|
||||
func.coalesce(
|
||||
func.sum(
|
||||
case(
|
||||
(Transaction.type == TransactionType.DEPOSIT.value, Transaction.amount_kopeks),
|
||||
(
|
||||
and_(
|
||||
Transaction.type == TransactionType.DEPOSIT.value,
|
||||
Transaction.payment_method.in_(REAL_PAYMENT_METHODS),
|
||||
),
|
||||
Transaction.amount_kopeks,
|
||||
),
|
||||
(
|
||||
Transaction.type == TransactionType.SUBSCRIPTION_PAYMENT.value,
|
||||
func.abs(Transaction.amount_kopeks),
|
||||
@@ -1149,7 +1169,13 @@ class PartnerStatsService:
|
||||
func.coalesce(
|
||||
func.sum(
|
||||
case(
|
||||
(Transaction.type == TransactionType.DEPOSIT.value, Transaction.amount_kopeks),
|
||||
(
|
||||
and_(
|
||||
Transaction.type == TransactionType.DEPOSIT.value,
|
||||
Transaction.payment_method.in_(REAL_PAYMENT_METHODS),
|
||||
),
|
||||
Transaction.amount_kopeks,
|
||||
),
|
||||
(
|
||||
Transaction.type == TransactionType.SUBSCRIPTION_PAYMENT.value,
|
||||
func.abs(Transaction.amount_kopeks),
|
||||
|
||||
@@ -13,6 +13,7 @@ from .kassa_ai import KassaAiPaymentMixin
|
||||
from .mulenpay import MulenPayPaymentMixin
|
||||
from .pal24 import Pal24PaymentMixin
|
||||
from .platega import PlategaPaymentMixin
|
||||
from .riopay import RioPayPaymentMixin
|
||||
from .stars import TelegramStarsMixin
|
||||
from .tribute import TributePaymentMixin
|
||||
from .wata import WataPaymentMixin
|
||||
@@ -29,6 +30,7 @@ __all__ = [
|
||||
'Pal24PaymentMixin',
|
||||
'PaymentCommonMixin',
|
||||
'PlategaPaymentMixin',
|
||||
'RioPayPaymentMixin',
|
||||
'TelegramStarsMixin',
|
||||
'TributePaymentMixin',
|
||||
'WataPaymentMixin',
|
||||
|
||||
@@ -521,6 +521,17 @@ async def try_fulfill_guest_purchase(
|
||||
paid_at=datetime.now(UTC),
|
||||
)
|
||||
|
||||
# Code-only gifts (is_gift=True, no recipient) stay in PAID status
|
||||
# — buyer shares the code manually, recipient activates via cabinet/bot
|
||||
if existing and existing.is_gift and not existing.gift_recipient_type:
|
||||
await db.commit()
|
||||
logger.info(
|
||||
'Code-only gift marked as PAID, skipping fulfillment',
|
||||
purchase_token_prefix=purchase_token[:5],
|
||||
provider=provider_name,
|
||||
)
|
||||
return True
|
||||
|
||||
# Fulfill: create user, subscription, deliver (commits on success)
|
||||
await fulfill_purchase(db, purchase_token)
|
||||
|
||||
|
||||
@@ -0,0 +1,507 @@
|
||||
"""Mixin для интеграции с RioPay (api.riopay.online)."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import uuid
|
||||
from datetime import UTC, datetime, timedelta
|
||||
from typing import Any
|
||||
|
||||
from sqlalchemy import update
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.config import settings
|
||||
from app.database.crud.riopay import (
|
||||
create_riopay_payment as crud_create_riopay_payment,
|
||||
get_riopay_payment_by_order_id,
|
||||
get_riopay_payment_by_riopay_order_id,
|
||||
update_riopay_payment_status,
|
||||
)
|
||||
from app.database.crud.transaction import create_transaction
|
||||
from app.database.crud.user import get_user_by_id
|
||||
from app.database.models import PaymentMethod, TransactionType, User as UserModel
|
||||
from app.services.riopay_service import riopay_service
|
||||
from app.utils.payment_logger import payment_logger as logger
|
||||
from app.utils.user_utils import format_referrer_info
|
||||
|
||||
|
||||
# Маппинг статусов RioPay → internal
|
||||
RIOPAY_STATUS_MAP = {
|
||||
'COMPLETED': ('success', True),
|
||||
'CANCELED': ('canceled', False),
|
||||
'FAILED': ('failed', False),
|
||||
'EXPIRED': ('expired', False),
|
||||
'CREATED': ('pending', False),
|
||||
'PENDING': ('pending', False),
|
||||
}
|
||||
|
||||
|
||||
class RioPayPaymentMixin:
|
||||
"""Mixin для работы с платежами RioPay."""
|
||||
|
||||
async def create_riopay_payment(
|
||||
self,
|
||||
db: AsyncSession,
|
||||
*,
|
||||
user_id: int,
|
||||
amount_kopeks: int,
|
||||
description: str = 'Пополнение баланса',
|
||||
email: str | None = None,
|
||||
language: str = 'ru',
|
||||
) -> dict[str, Any] | None:
|
||||
"""
|
||||
Создает платеж RioPay.
|
||||
|
||||
Returns:
|
||||
Словарь с данными платежа или None при ошибке
|
||||
"""
|
||||
if not settings.is_riopay_enabled():
|
||||
logger.error('RioPay не настроен')
|
||||
return None
|
||||
|
||||
# Валидация лимитов
|
||||
if amount_kopeks < settings.RIOPAY_MIN_AMOUNT_KOPEKS:
|
||||
logger.warning(
|
||||
'RioPay: сумма меньше минимальной',
|
||||
amount_kopeks=amount_kopeks,
|
||||
RIOPAY_MIN_AMOUNT_KOPEKS=settings.RIOPAY_MIN_AMOUNT_KOPEKS,
|
||||
)
|
||||
return None
|
||||
|
||||
if amount_kopeks > settings.RIOPAY_MAX_AMOUNT_KOPEKS:
|
||||
logger.warning(
|
||||
'RioPay: сумма больше максимальной',
|
||||
amount_kopeks=amount_kopeks,
|
||||
RIOPAY_MAX_AMOUNT_KOPEKS=settings.RIOPAY_MAX_AMOUNT_KOPEKS,
|
||||
)
|
||||
return None
|
||||
|
||||
# Получаем telegram_id пользователя для order_id
|
||||
user = await get_user_by_id(db, user_id)
|
||||
tg_id = user.telegram_id if user else user_id
|
||||
|
||||
# Генерируем уникальный 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)
|
||||
|
||||
# Метаданные
|
||||
metadata = {
|
||||
'user_id': user_id,
|
||||
'amount_kopeks': amount_kopeks,
|
||||
'description': description,
|
||||
'language': language,
|
||||
'type': 'balance_topup',
|
||||
}
|
||||
|
||||
try:
|
||||
# Используем API для создания заказа
|
||||
result = await riopay_service.create_order(
|
||||
amount=amount_rubles,
|
||||
currency=currency,
|
||||
external_id=order_id,
|
||||
purpose=description,
|
||||
success_url=settings.RIOPAY_SUCCESS_URL,
|
||||
fail_url=settings.RIOPAY_FAIL_URL,
|
||||
)
|
||||
|
||||
payment_url = result.get('paymentLink')
|
||||
riopay_order_id = result.get('id')
|
||||
|
||||
if not payment_url:
|
||||
logger.error('RioPay API не вернул URL платежа', result=result)
|
||||
return None
|
||||
|
||||
logger.info(
|
||||
'RioPay API: создан заказ', order_id=order_id, riopay_order_id=riopay_order_id, payment_url=payment_url
|
||||
)
|
||||
|
||||
# Сохраняем в БД
|
||||
local_payment = await crud_create_riopay_payment(
|
||||
db=db,
|
||||
user_id=user_id,
|
||||
order_id=order_id,
|
||||
amount_kopeks=amount_kopeks,
|
||||
currency=currency,
|
||||
description=description,
|
||||
payment_url=payment_url,
|
||||
riopay_order_id=riopay_order_id,
|
||||
payment_method=result.get('paymentType'),
|
||||
expires_at=expires_at,
|
||||
metadata_json=metadata,
|
||||
)
|
||||
|
||||
logger.info(
|
||||
'RioPay: создан платеж',
|
||||
order_id=order_id,
|
||||
user_id=user_id,
|
||||
amount_rubles=amount_rubles,
|
||||
currency=currency,
|
||||
)
|
||||
|
||||
return {
|
||||
'order_id': order_id,
|
||||
'riopay_order_id': riopay_order_id,
|
||||
'amount_kopeks': amount_kopeks,
|
||||
'amount_rubles': amount_rubles,
|
||||
'currency': currency,
|
||||
'payment_url': payment_url,
|
||||
'expires_at': expires_at.isoformat(),
|
||||
'local_payment_id': local_payment.id,
|
||||
}
|
||||
|
||||
except Exception as e:
|
||||
logger.exception('RioPay: ошибка создания платежа', e=e)
|
||||
return None
|
||||
|
||||
async def process_riopay_webhook(
|
||||
self,
|
||||
db: AsyncSession,
|
||||
*,
|
||||
payload: dict[str, Any],
|
||||
) -> bool:
|
||||
"""
|
||||
Обрабатывает webhook от RioPay.
|
||||
|
||||
Подпись проверяется в webserver/payments.py до вызова этого метода.
|
||||
|
||||
Args:
|
||||
db: Сессия БД
|
||||
payload: JSON тело webhook
|
||||
|
||||
Returns:
|
||||
True если платеж успешно обработан
|
||||
"""
|
||||
try:
|
||||
# Извлекаем данные из payload
|
||||
riopay_order_id = payload.get('id')
|
||||
external_id = payload.get('externalId')
|
||||
riopay_status = payload.get('status')
|
||||
amount = payload.get('amount')
|
||||
|
||||
if not riopay_order_id or not riopay_status:
|
||||
logger.warning('RioPay webhook: отсутствуют обязательные поля', payload=payload)
|
||||
return False
|
||||
|
||||
# Ищем платеж по external_id (наш order_id) или riopay_order_id
|
||||
payment = None
|
||||
if external_id:
|
||||
payment = await get_riopay_payment_by_order_id(db, external_id)
|
||||
if not payment and riopay_order_id:
|
||||
payment = await get_riopay_payment_by_riopay_order_id(db, riopay_order_id)
|
||||
|
||||
if not payment:
|
||||
logger.warning(
|
||||
'RioPay webhook: платеж не найден',
|
||||
external_id=external_id,
|
||||
riopay_order_id=riopay_order_id,
|
||||
)
|
||||
return False
|
||||
|
||||
# Проверка дублирования
|
||||
if payment.is_paid:
|
||||
logger.info('RioPay webhook: платеж уже обработан', order_id=payment.order_id)
|
||||
return True
|
||||
|
||||
# Маппинг статуса
|
||||
status_info = RIOPAY_STATUS_MAP.get(riopay_status, ('pending', False))
|
||||
internal_status, is_paid = status_info
|
||||
|
||||
callback_payload = {
|
||||
'riopay_order_id': riopay_order_id,
|
||||
'external_id': external_id,
|
||||
'status': riopay_status,
|
||||
'amount': amount,
|
||||
'payment_type': payload.get('paymentType'),
|
||||
'included_fee': payload.get('includedFee'),
|
||||
}
|
||||
|
||||
# Проверка суммы ДО обновления статуса
|
||||
if is_paid and amount is not None:
|
||||
expected = payment.amount_kopeks / 100
|
||||
if abs(float(amount) - expected) > 0.01:
|
||||
logger.error(
|
||||
'RioPay amount mismatch',
|
||||
expected=expected,
|
||||
received=amount,
|
||||
order_id=payment.order_id,
|
||||
)
|
||||
await update_riopay_payment_status(
|
||||
db=db,
|
||||
payment=payment,
|
||||
status='amount_mismatch',
|
||||
is_paid=False,
|
||||
riopay_order_id=riopay_order_id,
|
||||
payment_method=payload.get('paymentType'),
|
||||
callback_payload=callback_payload,
|
||||
)
|
||||
return False
|
||||
|
||||
# Обновляем статус платежа только после проверки суммы
|
||||
payment = await update_riopay_payment_status(
|
||||
db=db,
|
||||
payment=payment,
|
||||
status=internal_status,
|
||||
is_paid=is_paid,
|
||||
riopay_order_id=riopay_order_id,
|
||||
payment_method=payload.get('paymentType'),
|
||||
callback_payload=callback_payload,
|
||||
)
|
||||
|
||||
# Финализируем платеж если оплачен
|
||||
if is_paid:
|
||||
return await self._finalize_riopay_payment(
|
||||
db, payment, riopay_order_id=riopay_order_id, trigger='webhook'
|
||||
)
|
||||
|
||||
return True
|
||||
|
||||
except Exception as e:
|
||||
logger.exception('RioPay webhook: ошибка обработки', e=e)
|
||||
return False
|
||||
|
||||
async def _finalize_riopay_payment(
|
||||
self,
|
||||
db: AsyncSession,
|
||||
payment: Any,
|
||||
*,
|
||||
riopay_order_id: str | None,
|
||||
trigger: str,
|
||||
) -> bool:
|
||||
"""Создаёт транзакцию, начисляет баланс и отправляет уведомления."""
|
||||
if payment.transaction_id:
|
||||
logger.info('RioPay платеж уже привязан к транзакции', order_id=payment.order_id, trigger=trigger)
|
||||
return True
|
||||
|
||||
# Получаем пользователя
|
||||
user = await get_user_by_id(db, payment.user_id)
|
||||
if not user:
|
||||
logger.error(
|
||||
'Пользователь не найден для RioPay платежа',
|
||||
user_id=payment.user_id,
|
||||
order_id=payment.order_id,
|
||||
trigger=trigger,
|
||||
)
|
||||
return False
|
||||
|
||||
# Создаем транзакцию
|
||||
transaction = await create_transaction(
|
||||
db,
|
||||
user_id=payment.user_id,
|
||||
type=TransactionType.DEPOSIT,
|
||||
amount_kopeks=payment.amount_kopeks,
|
||||
description=f'Пополнение через RioPay (#{riopay_order_id or payment.order_id})',
|
||||
payment_method=PaymentMethod.RIOPAY,
|
||||
external_id=str(riopay_order_id) if riopay_order_id else payment.order_id,
|
||||
is_completed=True,
|
||||
created_at=getattr(payment, 'created_at', None),
|
||||
)
|
||||
|
||||
# Связываем платеж с транзакцией
|
||||
await update_riopay_payment_status(
|
||||
db=db,
|
||||
payment=payment,
|
||||
status=payment.status,
|
||||
transaction_id=transaction.id,
|
||||
)
|
||||
|
||||
old_balance = user.balance_kopeks
|
||||
was_first_topup = not user.has_made_first_topup
|
||||
|
||||
# Атомарное начисление баланса через SQL UPDATE, чтобы избежать race condition
|
||||
# при одновременных вебхуках / check_riopay_payment_status
|
||||
update_values: dict[str, Any] = {
|
||||
UserModel.balance_kopeks: UserModel.balance_kopeks + payment.amount_kopeks,
|
||||
UserModel.updated_at: datetime.now(UTC),
|
||||
}
|
||||
if was_first_topup:
|
||||
update_values[UserModel.has_made_first_topup] = True
|
||||
|
||||
await db.execute(update(UserModel).where(UserModel.id == user.id).values(update_values))
|
||||
|
||||
promo_group = user.get_primary_promo_group()
|
||||
subscription = getattr(user, 'subscription', None)
|
||||
referrer_info = format_referrer_info(user)
|
||||
topup_status = 'Первое пополнение' if was_first_topup else 'Пополнение'
|
||||
|
||||
await db.commit()
|
||||
|
||||
# Обработка реферального пополнения
|
||||
try:
|
||||
from app.services.referral_service import process_referral_topup
|
||||
|
||||
await process_referral_topup(db, user.id, payment.amount_kopeks, getattr(self, 'bot', None))
|
||||
except Exception as error:
|
||||
logger.error('Ошибка обработки реферального пополнения RioPay', error=error)
|
||||
|
||||
await db.refresh(user)
|
||||
await db.refresh(payment)
|
||||
|
||||
# Отправка уведомления админам
|
||||
if getattr(self, 'bot', None):
|
||||
try:
|
||||
from app.services.admin_notification_service import (
|
||||
AdminNotificationService,
|
||||
)
|
||||
|
||||
notification_service = AdminNotificationService(self.bot)
|
||||
await notification_service.send_balance_topup_notification(
|
||||
user,
|
||||
transaction,
|
||||
old_balance,
|
||||
topup_status=topup_status,
|
||||
referrer_info=referrer_info,
|
||||
subscription=subscription,
|
||||
promo_group=promo_group,
|
||||
db=db,
|
||||
)
|
||||
except Exception as error:
|
||||
logger.error('Ошибка отправки админ уведомления RioPay', error=error)
|
||||
|
||||
# Отправка уведомления пользователю (только Telegram-пользователям)
|
||||
if getattr(self, 'bot', None) and user.telegram_id:
|
||||
try:
|
||||
display_name = settings.get_riopay_display_name()
|
||||
|
||||
keyboard = await self.build_topup_success_keyboard(user)
|
||||
message = (
|
||||
'✅ <b>Пополнение успешно!</b>\n\n'
|
||||
f'💰 Сумма: {settings.format_price(payment.amount_kopeks)}\n'
|
||||
f'💳 Способ: {display_name}\n'
|
||||
f'🆔 Транзакция: {transaction.id}\n\n'
|
||||
'Баланс пополнен автоматически!'
|
||||
)
|
||||
|
||||
await self.bot.send_message(
|
||||
user.telegram_id,
|
||||
message,
|
||||
parse_mode='HTML',
|
||||
reply_markup=keyboard,
|
||||
)
|
||||
except Exception as error:
|
||||
logger.error('Ошибка отправки уведомления пользователю RioPay', error=error)
|
||||
|
||||
# Автопокупка подписки и уведомление о корзине
|
||||
try:
|
||||
from app.services.payment.common import send_cart_notification_after_topup
|
||||
|
||||
await send_cart_notification_after_topup(user, payment.amount_kopeks, db, getattr(self, 'bot', None))
|
||||
except Exception as error:
|
||||
logger.error(
|
||||
'Ошибка при работе с сохраненной корзиной для пользователя', user_id=user.id, error=error, exc_info=True
|
||||
)
|
||||
|
||||
logger.info(
|
||||
'Обработан RioPay платеж',
|
||||
order_id=payment.order_id,
|
||||
user_id=payment.user_id,
|
||||
trigger=trigger,
|
||||
)
|
||||
|
||||
return True
|
||||
|
||||
async def check_riopay_payment_status(
|
||||
self,
|
||||
db: AsyncSession,
|
||||
order_id: str,
|
||||
) -> dict[str, Any] | None:
|
||||
"""
|
||||
Проверяет статус платежа через API.
|
||||
"""
|
||||
try:
|
||||
payment = await get_riopay_payment_by_order_id(db, order_id)
|
||||
if not payment:
|
||||
logger.warning('RioPay payment not found', order_id=order_id)
|
||||
return None
|
||||
|
||||
if payment.is_paid:
|
||||
return {
|
||||
'payment': payment,
|
||||
'status': 'success',
|
||||
'is_paid': True,
|
||||
}
|
||||
|
||||
# Проверяем через API по riopay_order_id (UUID)
|
||||
if payment.riopay_order_id:
|
||||
try:
|
||||
order_data = await riopay_service.get_order(payment.riopay_order_id)
|
||||
riopay_status = order_data.get('status')
|
||||
|
||||
if riopay_status:
|
||||
status_info = RIOPAY_STATUS_MAP.get(riopay_status, ('pending', False))
|
||||
internal_status, is_paid = status_info
|
||||
|
||||
if is_paid:
|
||||
# Проверка суммы ДО обновления статуса
|
||||
api_amount = order_data.get('amount')
|
||||
if api_amount is not None:
|
||||
expected = payment.amount_kopeks / 100
|
||||
if abs(float(api_amount) - expected) > 0.01:
|
||||
logger.error(
|
||||
'RioPay amount mismatch (API check)',
|
||||
expected=expected,
|
||||
received=api_amount,
|
||||
order_id=payment.order_id,
|
||||
)
|
||||
await update_riopay_payment_status(
|
||||
db=db,
|
||||
payment=payment,
|
||||
status='amount_mismatch',
|
||||
is_paid=False,
|
||||
riopay_order_id=payment.riopay_order_id,
|
||||
callback_payload={
|
||||
'check_source': 'api',
|
||||
'riopay_order_data': order_data,
|
||||
},
|
||||
)
|
||||
return {
|
||||
'payment': payment,
|
||||
'status': 'amount_mismatch',
|
||||
'is_paid': False,
|
||||
}
|
||||
|
||||
logger.info('RioPay payment confirmed via API', order_id=payment.order_id)
|
||||
|
||||
callback_payload = {
|
||||
'check_source': 'api',
|
||||
'riopay_order_data': order_data,
|
||||
}
|
||||
|
||||
payment = await update_riopay_payment_status(
|
||||
db=db,
|
||||
payment=payment,
|
||||
status='success',
|
||||
is_paid=True,
|
||||
riopay_order_id=payment.riopay_order_id,
|
||||
payment_method=order_data.get('paymentType'),
|
||||
callback_payload=callback_payload,
|
||||
)
|
||||
|
||||
await self._finalize_riopay_payment(
|
||||
db,
|
||||
payment,
|
||||
riopay_order_id=payment.riopay_order_id,
|
||||
trigger='api_check',
|
||||
)
|
||||
elif internal_status != payment.status:
|
||||
# Обновляем статус если изменился
|
||||
payment = await update_riopay_payment_status(
|
||||
db=db,
|
||||
payment=payment,
|
||||
status=internal_status,
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
logger.error('Error checking RioPay payment status via API', e=e)
|
||||
|
||||
return {
|
||||
'payment': payment,
|
||||
'status': payment.status or 'pending',
|
||||
'is_paid': payment.is_paid,
|
||||
}
|
||||
|
||||
except Exception as e:
|
||||
logger.exception('RioPay: ошибка проверки статуса', e=e)
|
||||
return None
|
||||
@@ -345,6 +345,7 @@ class TelegramStarsMixin:
|
||||
transaction,
|
||||
period_display,
|
||||
was_trial_conversion=False,
|
||||
purchase_type='renewal' if user.has_had_paid_subscription else 'first_purchase',
|
||||
)
|
||||
except Exception as admin_error: # pragma: no cover - диагностический лог
|
||||
logger.error(
|
||||
|
||||
@@ -386,6 +386,7 @@ class YooKassaPaymentMixin:
|
||||
self,
|
||||
db: AsyncSession,
|
||||
payment: YooKassaPayment,
|
||||
event_object: dict[str, Any] | None = None,
|
||||
) -> bool:
|
||||
"""Переносит успешный платёж YooKassa в транзакции и начисляет баланс пользователю."""
|
||||
try:
|
||||
@@ -590,6 +591,7 @@ class YooKassaPaymentMixin:
|
||||
payment_type = payment_metadata.get('type', '')
|
||||
is_simple_subscription = payment_purpose == 'simple_subscription_purchase'
|
||||
is_trial_payment = payment_type == 'trial'
|
||||
is_recurrent_topup = payment_metadata.get('purpose') == 'recurrent_topup'
|
||||
|
||||
transaction_type = (
|
||||
TransactionType.SUBSCRIPTION_PAYMENT
|
||||
@@ -767,7 +769,7 @@ class YooKassaPaymentMixin:
|
||||
)
|
||||
|
||||
# Используем full_user для форматирования реферальной информации, чтобы избежать проблем с ленивой загрузкой
|
||||
user_for_referrer = full_user if full_user else user
|
||||
user_for_referrer = full_user or user
|
||||
referrer_info = format_referrer_info(user_for_referrer)
|
||||
topup_status = '🆕 Первое пополнение' if was_first_topup else '🔄 Пополнение'
|
||||
|
||||
@@ -825,36 +827,38 @@ class YooKassaPaymentMixin:
|
||||
'Ошибка отправки уведомления админам о YooKassa пополнении', error=error, exc_info=True
|
||||
)
|
||||
|
||||
# Отправляем уведомление пользователю (только Telegram-пользователям)
|
||||
if getattr(self, 'bot', None) and user.telegram_id:
|
||||
# Для рекуррентных автоплатежей уведомления отправляет recurrent_payment_service
|
||||
if not is_recurrent_topup:
|
||||
# Отправляем уведомление пользователю (только Telegram-пользователям)
|
||||
if getattr(self, 'bot', None) and user.telegram_id:
|
||||
try:
|
||||
# Передаем только простые данные, чтобы избежать проблем с ленивой загрузкой
|
||||
await self._send_payment_success_notification(
|
||||
user.telegram_id,
|
||||
payment.amount_kopeks,
|
||||
user=None, # Передаем None, чтобы _ensure_user_snapshot загрузил данные сам
|
||||
db=db,
|
||||
payment_method_title='Банковская карта (YooKassa)',
|
||||
)
|
||||
logger.info('Уведомление пользователю о платеже отправлено успешно')
|
||||
except Exception as error:
|
||||
logger.error('Ошибка отправки уведомления о платеже', error=error, exc_info=True)
|
||||
|
||||
# Проверяем наличие сохраненной корзины для возврата к оформлению подписки
|
||||
# ВАЖНО: этот код должен выполняться даже при ошибках в уведомлениях
|
||||
try:
|
||||
# Передаем только простые данные, чтобы избежать проблем с ленивой загрузкой
|
||||
await self._send_payment_success_notification(
|
||||
user.telegram_id,
|
||||
payment.amount_kopeks,
|
||||
user=None, # Передаем None, чтобы _ensure_user_snapshot загрузил данные сам
|
||||
db=db,
|
||||
payment_method_title='Банковская карта (YooKassa)',
|
||||
from app.services.payment.common import send_cart_notification_after_topup
|
||||
|
||||
await send_cart_notification_after_topup(
|
||||
user, payment.amount_kopeks, db, getattr(self, 'bot', None)
|
||||
)
|
||||
except Exception as e:
|
||||
logger.error(
|
||||
'Ошибка при работе с сохраненной корзиной для пользователя',
|
||||
user_id=user.id,
|
||||
error=e,
|
||||
exc_info=True,
|
||||
)
|
||||
logger.info('Уведомление пользователю о платеже отправлено успешно')
|
||||
except Exception as error:
|
||||
logger.error('Ошибка отправки уведомления о платеже', error=error, exc_info=True)
|
||||
|
||||
# Проверяем наличие сохраненной корзины для возврата к оформлению подписки
|
||||
# ВАЖНО: этот код должен выполняться даже при ошибках в уведомлениях
|
||||
try:
|
||||
from app.services.payment.common import send_cart_notification_after_topup
|
||||
|
||||
await send_cart_notification_after_topup(
|
||||
user, payment.amount_kopeks, db, getattr(self, 'bot', None)
|
||||
)
|
||||
except Exception as e:
|
||||
logger.error(
|
||||
'Ошибка при работе с сохраненной корзиной для пользователя',
|
||||
user_id=user.id,
|
||||
error=e,
|
||||
exc_info=True,
|
||||
)
|
||||
|
||||
if is_simple_subscription:
|
||||
logger.info('Обнаружен платеж простой покупки подписки для пользователя', user_id=user.id)
|
||||
@@ -972,6 +976,9 @@ class YooKassaPaymentMixin:
|
||||
transaction,
|
||||
subscription_period,
|
||||
was_trial_conversion=False,
|
||||
purchase_type='renewal'
|
||||
if (full_user or user).has_had_paid_subscription
|
||||
else 'first_purchase',
|
||||
)
|
||||
except Exception as admin_error:
|
||||
logger.error(
|
||||
@@ -1026,6 +1033,10 @@ class YooKassaPaymentMixin:
|
||||
amount_rubles=payment.amount_kopeks / 100,
|
||||
)
|
||||
|
||||
# Сохраняем привязанный метод оплаты для рекуррентных платежей
|
||||
if settings.YOOKASSA_RECURRENT_ENABLED and event_object:
|
||||
await self._save_payment_method_if_available(db, payment, event_object)
|
||||
|
||||
# Создаем чек через NaloGO (если NALOGO_ENABLED=true)
|
||||
if hasattr(self, 'nalogo_service') and self.nalogo_service:
|
||||
await self._create_nalogo_receipt(
|
||||
@@ -1086,6 +1097,85 @@ class YooKassaPaymentMixin:
|
||||
|
||||
return updated_metadata
|
||||
|
||||
async def _save_payment_method_if_available(
|
||||
self,
|
||||
db: AsyncSession,
|
||||
payment: YooKassaPayment,
|
||||
event_object: dict[str, Any],
|
||||
) -> None:
|
||||
"""Сохраняет привязанный метод оплаты из ответа YooKassa, если карта была сохранена."""
|
||||
try:
|
||||
pm = event_object.get('payment_method') or {}
|
||||
pm_id = pm.get('id')
|
||||
pm_saved = pm.get('saved', False)
|
||||
|
||||
if not pm_id or not pm_saved:
|
||||
return
|
||||
|
||||
from app.database.crud.saved_payment_method import (
|
||||
create_saved_payment_method,
|
||||
get_payment_method_by_yookassa_id,
|
||||
)
|
||||
|
||||
# Проверяем, не сохранён ли уже (включая деактивированные —
|
||||
# если пользователь удалил карту, не реактивируем её)
|
||||
existing = await get_payment_method_by_yookassa_id(db, pm_id, include_inactive=True)
|
||||
if existing:
|
||||
logger.debug(
|
||||
'Метод оплаты уже сохранён',
|
||||
yookassa_payment_method_id=pm_id,
|
||||
user_id=payment.user_id,
|
||||
is_active=existing.is_active,
|
||||
)
|
||||
return
|
||||
|
||||
# Извлекаем данные карты
|
||||
card = pm.get('card') or {}
|
||||
card_first6 = card.get('first6')
|
||||
card_last4 = card.get('last4')
|
||||
card_type = card.get('card_type')
|
||||
raw_month = card.get('expiry_month')
|
||||
raw_year = card.get('expiry_year')
|
||||
expiry_month = str(raw_month).zfill(2) if raw_month is not None else None
|
||||
expiry_year = str(raw_year) if raw_year is not None else None
|
||||
method_type = pm.get('type', 'bank_card')
|
||||
|
||||
# Формируем название
|
||||
title = None
|
||||
if card_last4:
|
||||
type_label = card_type or 'Card'
|
||||
title = f'{type_label} *{card_last4}'
|
||||
|
||||
saved = await create_saved_payment_method(
|
||||
db=db,
|
||||
user_id=payment.user_id,
|
||||
yookassa_payment_method_id=pm_id,
|
||||
method_type=method_type,
|
||||
card_first6=card_first6,
|
||||
card_last4=card_last4,
|
||||
card_type=card_type,
|
||||
card_expiry_month=expiry_month,
|
||||
card_expiry_year=expiry_year,
|
||||
title=title,
|
||||
)
|
||||
|
||||
if saved:
|
||||
logger.info(
|
||||
'Метод оплаты сохранён для рекуррентных платежей',
|
||||
saved_method_id=saved.id,
|
||||
user_id=payment.user_id,
|
||||
card_last4=card_last4,
|
||||
method_type=method_type,
|
||||
)
|
||||
|
||||
except Exception as save_error:
|
||||
logger.error(
|
||||
'Ошибка сохранения метода оплаты',
|
||||
yookassa_payment_id=payment.yookassa_payment_id,
|
||||
save_error=save_error,
|
||||
exc_info=True,
|
||||
)
|
||||
|
||||
async def _create_nalogo_receipt(
|
||||
self,
|
||||
db: AsyncSession,
|
||||
@@ -1223,7 +1313,7 @@ class YooKassaPaymentMixin:
|
||||
await db.refresh(payment)
|
||||
|
||||
if payment.status == 'succeeded' and payment.is_paid:
|
||||
return await self._process_successful_yookassa_payment(db, payment)
|
||||
return await self._process_successful_yookassa_payment(db, payment, event_object=event_object)
|
||||
|
||||
logger.info(
|
||||
'Webhook YooKassa обновил платеж до статуса',
|
||||
|
||||
@@ -128,6 +128,13 @@ def _get_method_defaults() -> dict:
|
||||
'default_max': settings.KASSA_AI_MAX_AMOUNT_KOPEKS,
|
||||
'available_sub_options': None,
|
||||
},
|
||||
'riopay': {
|
||||
'default_display_name': settings.get_riopay_display_name(),
|
||||
'is_configured': settings.is_riopay_enabled(),
|
||||
'default_min': settings.RIOPAY_MIN_AMOUNT_KOPEKS,
|
||||
'default_max': settings.RIOPAY_MAX_AMOUNT_KOPEKS,
|
||||
'available_sub_options': None,
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
@@ -147,7 +154,7 @@ def _get_platega_sub_options() -> list[dict] | None:
|
||||
'name': info.get('title') or info.get('name') or f'Platega {method_code}',
|
||||
}
|
||||
)
|
||||
return options if options else None
|
||||
return options or None
|
||||
except Exception:
|
||||
return None
|
||||
|
||||
@@ -168,6 +175,7 @@ DEFAULT_METHOD_ORDER = [
|
||||
'freekassa_card',
|
||||
'cloudpayments',
|
||||
'kassa_ai',
|
||||
'riopay',
|
||||
]
|
||||
|
||||
|
||||
|
||||
@@ -33,6 +33,7 @@ from app.services.payment import (
|
||||
from app.services.payment.cloudpayments import CloudPaymentsPaymentMixin
|
||||
from app.services.payment.freekassa import FreekassaPaymentMixin
|
||||
from app.services.payment.kassa_ai import KassaAiPaymentMixin
|
||||
from app.services.payment.riopay import RioPayPaymentMixin
|
||||
from app.services.platega_service import PlategaService
|
||||
from app.services.wata_service import WataService
|
||||
from app.services.yookassa_service import YooKassaService
|
||||
@@ -316,6 +317,7 @@ class PaymentService(
|
||||
CloudPaymentsPaymentMixin,
|
||||
FreekassaPaymentMixin,
|
||||
KassaAiPaymentMixin,
|
||||
RioPayPaymentMixin,
|
||||
):
|
||||
"""Основной интерфейс платежей, делегирующий работу специализированным mixin-ам."""
|
||||
|
||||
@@ -684,6 +686,53 @@ class PaymentService(
|
||||
}
|
||||
return None
|
||||
|
||||
# --- Telegram Stars ---------------------------------------------------
|
||||
if payment_method == 'telegram_stars':
|
||||
if not settings.TELEGRAM_STARS_ENABLED:
|
||||
logger.warning('Telegram Stars is not enabled, cannot create guest payment')
|
||||
return None
|
||||
|
||||
if self.bot is None:
|
||||
logger.warning('Bot instance required for Stars guest payment')
|
||||
return None
|
||||
|
||||
from aiogram.types import LabeledPrice
|
||||
|
||||
rate = settings.get_stars_rate()
|
||||
if rate <= 0:
|
||||
logger.error('TELEGRAM_STARS_RATE_RUB is not positive, cannot create Stars invoice')
|
||||
return None
|
||||
|
||||
amount_rubles = amount_kopeks / 100
|
||||
stars_amount = max(1, round(amount_rubles / rate))
|
||||
|
||||
payload = f'guest_purchase_{purchase_token}'
|
||||
|
||||
try:
|
||||
invoice_url = await self.bot.create_invoice_link(
|
||||
title='Подарочная подписка VPN',
|
||||
description=f'{description} ({stars_amount} ⭐)',
|
||||
payload=payload,
|
||||
provider_token='',
|
||||
currency='XTR',
|
||||
prices=[LabeledPrice(label='Подарочная подписка', amount=stars_amount)],
|
||||
)
|
||||
|
||||
logger.info(
|
||||
'Created Stars invoice for guest purchase',
|
||||
stars_amount=stars_amount,
|
||||
purchase_token_prefix=purchase_token[:5],
|
||||
)
|
||||
return {
|
||||
'payment_url': invoice_url,
|
||||
'payment_id': f'stars_{purchase_token[:12]}',
|
||||
'provider': 'telegram_stars',
|
||||
}
|
||||
|
||||
except Exception as stars_error:
|
||||
logger.error('Error creating Stars invoice for guest payment', error=stars_error)
|
||||
return None
|
||||
|
||||
# --- Unsupported provider ---------------------------------------------
|
||||
logger.warning(
|
||||
'Guest payment requested for unsupported provider',
|
||||
|
||||
@@ -0,0 +1,391 @@
|
||||
"""Сервис рекуррентных автоплатежей через YooKassa.
|
||||
|
||||
Находит подписки с autopay, у которых недостаточно баланса для продления,
|
||||
и пополняет баланс с сохранённой карты. Существующий autopay в
|
||||
monitoring_service затем спишет баланс и продлит подписку.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass, field
|
||||
from datetime import UTC, datetime, timedelta
|
||||
|
||||
import structlog
|
||||
from aiogram import Bot
|
||||
from aiogram.types import InlineKeyboardButton, InlineKeyboardMarkup
|
||||
from sqlalchemy import and_, or_, select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from sqlalchemy.orm import selectinload
|
||||
|
||||
from app.config import settings
|
||||
from app.database.models import (
|
||||
Subscription,
|
||||
SubscriptionStatus,
|
||||
User,
|
||||
UserPromoGroup,
|
||||
)
|
||||
|
||||
|
||||
logger = structlog.get_logger(__name__)
|
||||
|
||||
|
||||
@dataclass
|
||||
class _DailyGuard:
|
||||
"""Защита от повторной обработки подписок в рамках одного дня."""
|
||||
|
||||
date: str = ''
|
||||
processed: set[str] = field(default_factory=set)
|
||||
|
||||
def reset_if_new_day(self) -> None:
|
||||
today = datetime.now(UTC).strftime('%Y-%m-%d')
|
||||
if today != self.date:
|
||||
self.processed = set()
|
||||
self.date = today
|
||||
|
||||
def is_processed(self, key: str) -> bool:
|
||||
return key in self.processed
|
||||
|
||||
def mark_processed(self, key: str) -> None:
|
||||
self.processed.add(key)
|
||||
|
||||
|
||||
_daily_guard = _DailyGuard()
|
||||
|
||||
|
||||
def _build_extend_keyboard(texts) -> InlineKeyboardMarkup:
|
||||
"""Клавиатура с кнопкой продления подписки для уведомлений."""
|
||||
return InlineKeyboardMarkup(
|
||||
inline_keyboard=[
|
||||
[
|
||||
InlineKeyboardButton(
|
||||
text=texts.t('SUBSCRIPTION_EXTEND', '💎 Продлить подписку'),
|
||||
callback_data='subscription_extend',
|
||||
)
|
||||
],
|
||||
]
|
||||
)
|
||||
|
||||
|
||||
async def process_recurrent_payments(db: AsyncSession, bot: Bot | None = None) -> dict:
|
||||
"""
|
||||
Основная функция: находит подписки, которым скоро нужно продление,
|
||||
у которых недостаточно баланса, и пополняет баланс с сохранённой карты.
|
||||
|
||||
Args:
|
||||
db: Сессия БД из вызывающего кода (_monitoring_cycle)
|
||||
bot: Экземпляр бота для уведомлений
|
||||
|
||||
Returns:
|
||||
dict: Статистика обработки
|
||||
"""
|
||||
if not settings.YOOKASSA_RECURRENT_ENABLED:
|
||||
return {'skipped': True, 'reason': 'recurrent_disabled'}
|
||||
|
||||
if not settings.YOOKASSA_ENABLED:
|
||||
return {'skipped': True, 'reason': 'yookassa_disabled'}
|
||||
|
||||
if not settings.ENABLE_AUTOPAY:
|
||||
return {'skipped': True, 'reason': 'autopay_disabled'}
|
||||
|
||||
_daily_guard.reset_if_new_day()
|
||||
|
||||
stats = {
|
||||
'checked': 0,
|
||||
'payments_created': 0,
|
||||
'insufficient_no_card': 0,
|
||||
'all_cards_failed': 0,
|
||||
'already_processed': 0,
|
||||
'errors': 0,
|
||||
}
|
||||
|
||||
# Создаём сервисы один раз для всех подписок
|
||||
from app.services.payment_service import PaymentService
|
||||
from app.services.subscription_service import SubscriptionService
|
||||
|
||||
payment_service = PaymentService()
|
||||
subscription_service = SubscriptionService()
|
||||
|
||||
try:
|
||||
subscriptions = await _find_subscriptions_needing_topup(db)
|
||||
stats['checked'] = len(subscriptions)
|
||||
|
||||
for subscription in subscriptions:
|
||||
user = subscription.user
|
||||
if not user:
|
||||
continue
|
||||
|
||||
guard_key = f'{user.id}_{subscription.id}'
|
||||
if _daily_guard.is_processed(guard_key):
|
||||
stats['already_processed'] += 1
|
||||
continue
|
||||
|
||||
try:
|
||||
result = await _process_single_subscription(
|
||||
db,
|
||||
subscription,
|
||||
user,
|
||||
bot,
|
||||
payment_service,
|
||||
subscription_service,
|
||||
)
|
||||
if result == 'created':
|
||||
stats['payments_created'] += 1
|
||||
_daily_guard.mark_processed(guard_key)
|
||||
elif result == 'no_card':
|
||||
stats['insufficient_no_card'] += 1
|
||||
_daily_guard.mark_processed(guard_key)
|
||||
elif result == 'all_cards_failed':
|
||||
stats['all_cards_failed'] += 1
|
||||
_daily_guard.mark_processed(guard_key)
|
||||
elif result == 'skipped':
|
||||
stats['already_processed'] += 1
|
||||
except Exception as e:
|
||||
stats['errors'] += 1
|
||||
logger.error(
|
||||
'Ошибка обработки рекуррентного платежа',
|
||||
subscription_id=subscription.id,
|
||||
user_id=user.id,
|
||||
error=e,
|
||||
exc_info=True,
|
||||
)
|
||||
except Exception as e:
|
||||
logger.error('Ошибка получения подписок для рекуррентных платежей', error=e, exc_info=True)
|
||||
stats['errors'] += 1
|
||||
|
||||
if stats['payments_created'] > 0 or stats['errors'] > 0:
|
||||
logger.info('Рекуррентные платежи: итоги', **stats)
|
||||
|
||||
return stats
|
||||
|
||||
|
||||
async def _find_subscriptions_needing_topup(db: AsyncSession) -> list:
|
||||
"""Находит подписки с autopay, которым скоро нужно продление."""
|
||||
current_time = datetime.now(UTC)
|
||||
max_days_before = settings.DEFAULT_AUTOPAY_DAYS_BEFORE
|
||||
|
||||
# Максимальный горизонт проверки
|
||||
check_horizon = current_time + timedelta(days=max_days_before + 1)
|
||||
|
||||
recently_expired_threshold = current_time - timedelta(hours=48)
|
||||
|
||||
result = await db.execute(
|
||||
select(Subscription)
|
||||
.options(
|
||||
selectinload(Subscription.user).options(
|
||||
selectinload(User.promo_group),
|
||||
selectinload(User.user_promo_groups).selectinload(UserPromoGroup.promo_group),
|
||||
),
|
||||
selectinload(Subscription.tariff),
|
||||
)
|
||||
.where(
|
||||
and_(
|
||||
or_(
|
||||
and_(
|
||||
Subscription.status == SubscriptionStatus.ACTIVE.value,
|
||||
Subscription.end_date <= check_horizon,
|
||||
),
|
||||
and_(
|
||||
Subscription.status == SubscriptionStatus.EXPIRED.value,
|
||||
Subscription.end_date >= recently_expired_threshold,
|
||||
),
|
||||
),
|
||||
Subscription.autopay_enabled == True,
|
||||
Subscription.is_trial == False,
|
||||
)
|
||||
)
|
||||
)
|
||||
return list(result.scalars().all())
|
||||
|
||||
|
||||
async def _process_single_subscription(
|
||||
db: AsyncSession,
|
||||
subscription: Subscription,
|
||||
user: User,
|
||||
bot: Bot | None,
|
||||
payment_service,
|
||||
subscription_service,
|
||||
) -> str:
|
||||
"""
|
||||
Обрабатывает одну подписку: проверяет баланс, находит карту, создаёт автоплатёж.
|
||||
|
||||
Returns:
|
||||
'created' — автоплатёж создан
|
||||
'no_card' — нет сохранённой карты
|
||||
'all_cards_failed' — все карты не сработали
|
||||
'skipped' — баланс достаточен или другая причина пропуска
|
||||
"""
|
||||
from app.database.crud.saved_payment_method import get_active_payment_methods_by_user
|
||||
|
||||
# Рассчитываем стоимость продления
|
||||
tariff = getattr(subscription, 'tariff', None)
|
||||
if tariff:
|
||||
autopay_period = tariff.get_shortest_period() or 30
|
||||
else:
|
||||
autopay_period = 30
|
||||
|
||||
try:
|
||||
renewal_cost = await subscription_service.calculate_renewal_price(
|
||||
subscription,
|
||||
autopay_period,
|
||||
db,
|
||||
user=user,
|
||||
)
|
||||
except Exception as e:
|
||||
logger.error(
|
||||
'Ошибка расчёта стоимости для рекуррентного платежа',
|
||||
subscription_id=subscription.id,
|
||||
user_id=user.id,
|
||||
error=e,
|
||||
)
|
||||
return 'skipped'
|
||||
|
||||
if renewal_cost <= 0:
|
||||
return 'skipped'
|
||||
|
||||
# Проверяем, хватает ли баланса
|
||||
shortage = renewal_cost - user.balance_kopeks
|
||||
if shortage <= 0:
|
||||
# Баланса достаточно, обычный autopay справится
|
||||
return 'skipped'
|
||||
|
||||
# Используем autopay_days_before конкретной подписки, если задан
|
||||
days_before = getattr(subscription, 'autopay_days_before', None) or settings.DEFAULT_AUTOPAY_DAYS_BEFORE
|
||||
days_until_expiry = (subscription.end_date - datetime.now(UTC)).total_seconds() / 86400
|
||||
if days_until_expiry > days_before and subscription.status != SubscriptionStatus.EXPIRED.value:
|
||||
return 'skipped'
|
||||
|
||||
# Нужно пополнить баланс — ищем сохранённую карту
|
||||
saved_methods = await get_active_payment_methods_by_user(db, user.id)
|
||||
if not saved_methods:
|
||||
return 'no_card'
|
||||
|
||||
# Сумма пополнения = нехватка (минимум YOOKASSA_MIN_AMOUNT_KOPEKS)
|
||||
min_amount = settings.YOOKASSA_MIN_AMOUNT_KOPEKS
|
||||
topup_amount_kopeks = max(shortage, min_amount)
|
||||
topup_amount_rubles = topup_amount_kopeks / 100
|
||||
|
||||
# Создаём автоплатёж
|
||||
yookassa_service = payment_service.yookassa_service
|
||||
if not yookassa_service or not yookassa_service.configured:
|
||||
logger.warning('YooKassa сервис не сконфигурирован для рекуррентных платежей')
|
||||
return 'skipped'
|
||||
|
||||
description = settings.get_balance_payment_description(topup_amount_kopeks)
|
||||
metadata = {
|
||||
'user_id': str(user.id),
|
||||
'user_telegram_id': str(user.telegram_id) if user.telegram_id else '',
|
||||
'purpose': 'recurrent_topup',
|
||||
'subscription_id': str(subscription.id),
|
||||
'source': 'recurrent_payment_service',
|
||||
}
|
||||
|
||||
# Перебираем все сохранённые карты пока не найдём рабочую
|
||||
today = datetime.now(UTC).strftime('%Y-%m-%d')
|
||||
for saved_method in saved_methods:
|
||||
# Детерминированный ключ: при рестарте/повторе YooKassa вернёт тот же платёж
|
||||
idem_key = f'recurrent_{subscription.id}_{saved_method.id}_{today}'
|
||||
result = await yookassa_service.create_autopayment(
|
||||
amount=topup_amount_rubles,
|
||||
currency='RUB',
|
||||
description=description,
|
||||
payment_method_id=saved_method.yookassa_payment_method_id,
|
||||
metadata=metadata,
|
||||
idempotence_key=idem_key,
|
||||
)
|
||||
|
||||
if not result:
|
||||
card_display = f'*{saved_method.card_last4}' if saved_method.card_last4 else ''
|
||||
logger.warning(
|
||||
'Не удалось списать с карты, пробуем следующую',
|
||||
user_id=user.id,
|
||||
subscription_id=subscription.id,
|
||||
payment_method_id=saved_method.yookassa_payment_method_id,
|
||||
card_display=card_display,
|
||||
)
|
||||
continue
|
||||
|
||||
# Успешно — сохраняем локальную запись с привязкой к YooKassa ID
|
||||
try:
|
||||
from app.database.crud.yookassa import create_yookassa_payment
|
||||
|
||||
yookassa_created_at = None
|
||||
if result.get('created_at'):
|
||||
try:
|
||||
yookassa_created_at = datetime.fromisoformat(result['created_at'].replace('Z', '+00:00'))
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
result_payment = await create_yookassa_payment(
|
||||
db=db,
|
||||
user_id=user.id,
|
||||
yookassa_payment_id=result['id'],
|
||||
amount_kopeks=topup_amount_kopeks,
|
||||
currency='RUB',
|
||||
description=description,
|
||||
status=result.get('status', 'pending'),
|
||||
metadata_json=metadata,
|
||||
yookassa_created_at=yookassa_created_at,
|
||||
test_mode=result.get('test_mode', False),
|
||||
)
|
||||
if result_payment:
|
||||
logger.info(
|
||||
'Рекуррентный автоплатёж создан',
|
||||
user_id=user.id,
|
||||
subscription_id=subscription.id,
|
||||
amount_kopeks=topup_amount_kopeks,
|
||||
yookassa_payment_id=result['id'],
|
||||
)
|
||||
except Exception as e:
|
||||
logger.warning('Ошибка создания локальной записи рекуррентного платежа', error=e)
|
||||
|
||||
# Уведомляем пользователя
|
||||
if bot and user.telegram_id:
|
||||
try:
|
||||
from app.localization.texts import get_texts
|
||||
|
||||
texts = get_texts(user.language)
|
||||
payment_status = result.get('status', '')
|
||||
if result.get('paid'):
|
||||
keyboard = _build_extend_keyboard(texts)
|
||||
msg = texts.t(
|
||||
'RECURRENT_TOPUP_SUCCESS',
|
||||
'✅ <b>Автоплатёж выполнен</b>\n\nБаланс пополнен на {amount} для продления подписки.',
|
||||
).format(amount=settings.format_price(topup_amount_kopeks))
|
||||
await bot.send_message(
|
||||
chat_id=user.telegram_id,
|
||||
text=msg,
|
||||
parse_mode='HTML',
|
||||
reply_markup=keyboard,
|
||||
)
|
||||
elif payment_status == 'pending':
|
||||
logger.info(
|
||||
'Рекуррентный платёж в обработке',
|
||||
user_id=user.id,
|
||||
yookassa_payment_id=result.get('id'),
|
||||
)
|
||||
except Exception as notify_error:
|
||||
logger.warning('Ошибка уведомления об автоплатеже', notify_error=notify_error)
|
||||
|
||||
return 'created'
|
||||
|
||||
# Все карты не сработали — уведомляем пользователя
|
||||
if bot and user.telegram_id:
|
||||
try:
|
||||
from app.localization.texts import get_texts
|
||||
|
||||
texts = get_texts(user.language)
|
||||
keyboard = _build_extend_keyboard(texts)
|
||||
msg = texts.t(
|
||||
'RECURRENT_TOPUP_FAILED',
|
||||
'❌ <b>Автоплатёж не удался</b>\n\nНе удалось списать {amount} ни с одной сохранённой карты для продления подписки.\n\nПополните баланс вручную, чтобы подписка не прервалась.',
|
||||
).format(amount=settings.format_price(topup_amount_kopeks))
|
||||
await bot.send_message(
|
||||
chat_id=user.telegram_id,
|
||||
text=msg,
|
||||
parse_mode='HTML',
|
||||
reply_markup=keyboard,
|
||||
)
|
||||
except Exception as notify_error:
|
||||
logger.warning('Ошибка уведомления о неудачном автоплатеже', notify_error=notify_error)
|
||||
|
||||
return 'all_cards_failed'
|
||||
@@ -4,7 +4,7 @@ from sqlalchemy import delete
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.config import settings
|
||||
from app.database.crud.referral import create_referral_earning, get_user_campaign_id
|
||||
from app.database.crud.referral import create_referral_earning, get_commission_payment_count, get_user_campaign_id
|
||||
from app.database.crud.user import add_user_balance, get_user_by_id
|
||||
from app.database.models import ReferralEarning, TransactionType, User
|
||||
from app.services.notification_delivery_service import (
|
||||
@@ -16,6 +16,23 @@ from app.utils.user_utils import get_effective_referral_commission_percent
|
||||
logger = structlog.get_logger(__name__)
|
||||
|
||||
|
||||
async def _is_commission_limit_reached(db: AsyncSession, referrer_id: int, referral_id: int) -> bool:
|
||||
"""Проверяет, исчерпан ли лимит комиссионных платежей для пары реферер-реферал."""
|
||||
if settings.REFERRAL_MAX_COMMISSION_PAYMENTS <= 0:
|
||||
return False
|
||||
paid_count = await get_commission_payment_count(db, referrer_id, referral_id)
|
||||
if paid_count >= settings.REFERRAL_MAX_COMMISSION_PAYMENTS:
|
||||
logger.info(
|
||||
'Лимит комиссионных платежей исчерпан',
|
||||
referrer_id=referrer_id,
|
||||
referral_id=referral_id,
|
||||
paid_count=paid_count,
|
||||
max_payments=settings.REFERRAL_MAX_COMMISSION_PAYMENTS,
|
||||
)
|
||||
return True
|
||||
return False
|
||||
|
||||
|
||||
async def send_referral_notification(
|
||||
bot: Bot,
|
||||
telegram_id: int | None,
|
||||
@@ -99,19 +116,28 @@ async def process_referral_registration(db: AsyncSession, new_user_id: int, refe
|
||||
commission_percent = get_effective_referral_commission_percent(referrer)
|
||||
referral_notification = (
|
||||
f'🎉 <b>Добро пожаловать!</b>\n\n'
|
||||
f'Вы перешли по реферальной ссылке пользователя <b>{referrer.full_name}</b>!\n\n'
|
||||
f'💰 При первом пополнении от {settings.format_price(settings.REFERRAL_MINIMUM_TOPUP_KOPEKS)} '
|
||||
f'вы получите бонус {settings.format_price(settings.REFERRAL_FIRST_TOPUP_BONUS_KOPEKS)}!\n\n'
|
||||
# f"🎁 Ваш реферер также получит награду за ваше первое пополнение."
|
||||
f'Вы перешли по реферальной ссылке пользователя <b>{referrer.full_name}</b>!'
|
||||
)
|
||||
if settings.REFERRAL_FIRST_TOPUP_BONUS_KOPEKS > 0:
|
||||
referral_notification += (
|
||||
f'\n\n💰 При первом пополнении от {settings.format_price(settings.REFERRAL_MINIMUM_TOPUP_KOPEKS)} '
|
||||
f'вы получите бонус {settings.format_price(settings.REFERRAL_FIRST_TOPUP_BONUS_KOPEKS)}!'
|
||||
)
|
||||
await send_referral_notification(bot, new_user.telegram_id, referral_notification, user=new_user)
|
||||
|
||||
inviter_notification = (
|
||||
f'👥 <b>Новый реферал!</b>\n\n'
|
||||
f'По вашей ссылке зарегистрировался пользователь <b>{new_user.full_name}</b>!\n\n'
|
||||
f'💰 Когда он пополнит баланс от {settings.format_price(settings.REFERRAL_MINIMUM_TOPUP_KOPEKS)}, '
|
||||
f'вы получите минимум {settings.format_price(settings.REFERRAL_INVITER_BONUS_KOPEKS)} или '
|
||||
f'{commission_percent}% от суммы (что больше).\n\n'
|
||||
)
|
||||
if settings.REFERRAL_INVITER_BONUS_KOPEKS > 0:
|
||||
inviter_notification += (
|
||||
f'вы получите минимум {settings.format_price(settings.REFERRAL_INVITER_BONUS_KOPEKS)} или '
|
||||
f'{commission_percent}% от суммы (что больше).\n\n'
|
||||
)
|
||||
else:
|
||||
inviter_notification += f'вы получите {commission_percent}% от суммы.\n\n'
|
||||
inviter_notification += (
|
||||
f'📈 С каждого последующего пополнения вы будете получать {commission_percent}% комиссии.'
|
||||
)
|
||||
await send_referral_notification(
|
||||
@@ -169,6 +195,9 @@ async def process_referral_topup(db: AsyncSession, user_id: int, topup_amount_ko
|
||||
topup_amount_kopeks=topup_amount_kopeks / 100,
|
||||
)
|
||||
|
||||
if commission_amount > 0 and await _is_commission_limit_reached(db, referrer.id, user.id):
|
||||
return True
|
||||
|
||||
if commission_amount > 0:
|
||||
balance_ok = await add_user_balance(
|
||||
db,
|
||||
@@ -325,6 +354,9 @@ async def process_referral_topup(db: AsyncSession, user_id: int, topup_amount_ko
|
||||
)
|
||||
|
||||
elif commission_amount > 0:
|
||||
if await _is_commission_limit_reached(db, referrer.id, user.id):
|
||||
return True
|
||||
|
||||
balance_ok = await add_user_balance(
|
||||
db,
|
||||
referrer,
|
||||
|
||||
@@ -278,7 +278,8 @@ class ReferralWithdrawalService:
|
||||
if referral_ids:
|
||||
month_ago = datetime.now(UTC) - timedelta(days=30)
|
||||
|
||||
# Одним запросом получаем статистику пополнений всех рефералов за месяц
|
||||
# Одним запросом получаем статистику реальных пополнений всех рефералов за месяц
|
||||
# (исключаем промо-бонусы с payment_method=NULL)
|
||||
ref_deposits_result = await db.execute(
|
||||
select(
|
||||
Transaction.user_id,
|
||||
@@ -290,6 +291,7 @@ class ReferralWithdrawalService:
|
||||
Transaction.type == TransactionType.DEPOSIT.value,
|
||||
Transaction.is_completed == True,
|
||||
Transaction.created_at >= month_ago,
|
||||
Transaction.payment_method.isnot(None),
|
||||
)
|
||||
.group_by(Transaction.user_id)
|
||||
)
|
||||
@@ -328,7 +330,7 @@ class ReferralWithdrawalService:
|
||||
if suspicious_referrals:
|
||||
analysis['flags'].append(f'⚠️ Подозрительная активность у {len(suspicious_referrals)} реферала(ов)')
|
||||
|
||||
# Общая статистика по рефералам (за всё время)
|
||||
# Общая статистика по рефералам (за всё время, только реальные платежи)
|
||||
all_ref_deposits = await db.execute(
|
||||
select(
|
||||
func.count(func.distinct(Transaction.user_id)).label('paying_count'),
|
||||
@@ -338,6 +340,7 @@ class ReferralWithdrawalService:
|
||||
Transaction.user_id.in_(referral_ids),
|
||||
Transaction.type == TransactionType.DEPOSIT.value,
|
||||
Transaction.is_completed == True,
|
||||
Transaction.payment_method.isnot(None),
|
||||
)
|
||||
)
|
||||
ref_stats = all_ref_deposits.fetchone()
|
||||
|
||||
@@ -1759,34 +1759,24 @@ class RemnaWaveService:
|
||||
# Конвертируем локальную дату из БД в UTC для корректного сравнения
|
||||
local_end_date_utc = self._local_to_utc(subscription.end_date)
|
||||
|
||||
# КРИТИЧНО: НЕ перезаписываем end_date если локальная дата ПОЗЖЕ
|
||||
# Это защищает от ситуации когда подписка была продлена в боте,
|
||||
# но RemnaWave ещё не получил обновление или вернул старую дату
|
||||
# Панель авторитетна для ACTIVE подписок — обновляем end_date
|
||||
# в обоих направлениях (как вперёд, так и назад)
|
||||
time_diff = abs((local_end_date_utc - expire_at).total_seconds())
|
||||
if time_diff > 60:
|
||||
if expire_at > local_end_date_utc:
|
||||
# RemnaWave имеет более позднюю дату - обновляем
|
||||
# Конвертируем UTC обратно в локальное время для сохранения в БД
|
||||
new_end_date_local = expire_at.replace(tzinfo=self._utc_timezone).astimezone(
|
||||
self._panel_timezone
|
||||
)
|
||||
logger.info(
|
||||
'✅ Sync: обновлена end_date для user -> (разница: с)',
|
||||
value=getattr(user, 'telegram_id', '?'),
|
||||
end_date=subscription.end_date,
|
||||
new_end_date_local=new_end_date_local,
|
||||
time_diff=round(time_diff, 0),
|
||||
)
|
||||
subscription.end_date = new_end_date_local
|
||||
else:
|
||||
# Локальная дата позже - НЕ перезаписываем
|
||||
logger.debug(
|
||||
'⏭️ Sync: end_date для user актуальна: локальная ( UTC: ) RemnaWave ( UTC)',
|
||||
value=getattr(user, 'telegram_id', '?'),
|
||||
end_date=subscription.end_date,
|
||||
local_end_date_utc=local_end_date_utc,
|
||||
expire_at=expire_at,
|
||||
)
|
||||
# Конвертируем UTC обратно в локальное время для сохранения в БД
|
||||
new_end_date_local = expire_at.replace(tzinfo=self._utc_timezone).astimezone(
|
||||
self._panel_timezone
|
||||
)
|
||||
direction = '→' if expire_at > local_end_date_utc else '←'
|
||||
logger.info(
|
||||
'✅ Sync: обновлена end_date для user -> (разница: с, направление: )',
|
||||
value=getattr(user, 'telegram_id', '?'),
|
||||
end_date=subscription.end_date,
|
||||
new_end_date_local=new_end_date_local,
|
||||
time_diff=round(time_diff, 0),
|
||||
direction=direction,
|
||||
)
|
||||
subscription.end_date = new_end_date_local
|
||||
else:
|
||||
logger.debug(
|
||||
'⏭️ Sync: пропускаем обновление end_date для user разница слишком мала (с < 60с)',
|
||||
|
||||
@@ -600,23 +600,22 @@ class RemnaWaveWebhookService:
|
||||
except (ValueError, TypeError):
|
||||
pass
|
||||
|
||||
# Sync expire date (only if panel date is LATER than local to prevent race condition
|
||||
# where webhook with stale expireAt overwrites a freshly extended subscription)
|
||||
# Sync expire date — panel is the source of truth for user.modified events
|
||||
expire_at = data.get('expireAt')
|
||||
if expire_at:
|
||||
try:
|
||||
parsed_dt = datetime.fromisoformat(expire_at.replace('Z', '+00:00'))
|
||||
new_end_date = parsed_dt.astimezone(UTC)
|
||||
if subscription.end_date != new_end_date:
|
||||
if not subscription.end_date or new_end_date > subscription.end_date:
|
||||
subscription.end_date = new_end_date
|
||||
changed = True
|
||||
else:
|
||||
logger.warning(
|
||||
'Webhook: пропуск перезаписи end_date — локальная дата позже',
|
||||
old_end_date = subscription.end_date
|
||||
subscription.end_date = new_end_date
|
||||
changed = True
|
||||
if old_end_date and new_end_date < old_end_date:
|
||||
logger.info(
|
||||
'Webhook: end_date обновлена назад (панель авторитетна): → ',
|
||||
subscription_id=subscription.id,
|
||||
local_end_date=subscription.end_date,
|
||||
webhook_end_date=new_end_date,
|
||||
old_end_date=old_end_date,
|
||||
new_end_date=new_end_date,
|
||||
)
|
||||
except (ValueError, TypeError):
|
||||
pass
|
||||
|
||||
@@ -0,0 +1,169 @@
|
||||
"""Сервис для работы с API RioPay (api.riopay.online) v2.0.1."""
|
||||
|
||||
import hashlib
|
||||
import hmac
|
||||
from typing import Any
|
||||
|
||||
import aiohttp
|
||||
import structlog
|
||||
|
||||
from app.config import settings
|
||||
|
||||
|
||||
logger = structlog.get_logger(__name__)
|
||||
|
||||
API_BASE_URL = 'https://api.riopay.online'
|
||||
|
||||
|
||||
class RioPayAPIError(Exception):
|
||||
"""Ошибка API RioPay."""
|
||||
|
||||
def __init__(self, status_code: int, message: str):
|
||||
self.status_code = status_code
|
||||
self.message = message
|
||||
super().__init__(f'RioPay API error ({status_code}): {message}')
|
||||
|
||||
|
||||
class RioPayService:
|
||||
"""Сервис для работы с API RioPay."""
|
||||
|
||||
def __init__(self):
|
||||
self._api_token: str | None = None
|
||||
self._session: aiohttp.ClientSession | None = None
|
||||
|
||||
@property
|
||||
def api_token(self) -> str:
|
||||
if self._api_token is None:
|
||||
self._api_token = settings.RIOPAY_API_TOKEN
|
||||
return self._api_token or ''
|
||||
|
||||
@property
|
||||
def webhook_secret(self) -> str:
|
||||
"""Ключ для HMAC-SHA512 верификации вебхуков. По умолчанию = api_token."""
|
||||
return settings.RIOPAY_WEBHOOK_SECRET or self.api_token
|
||||
|
||||
def _get_headers(self) -> dict[str, str]:
|
||||
"""Формирует заголовки для API запросов."""
|
||||
return {
|
||||
'x-api-token': self.api_token,
|
||||
'Content-Type': 'application/json',
|
||||
}
|
||||
|
||||
async def _get_session(self) -> aiohttp.ClientSession:
|
||||
"""Возвращает переиспользуемую HTTP-сессию."""
|
||||
if self._session is None or self._session.closed:
|
||||
self._session = aiohttp.ClientSession(
|
||||
timeout=aiohttp.ClientTimeout(total=30),
|
||||
)
|
||||
return self._session
|
||||
|
||||
async def close(self) -> None:
|
||||
"""Закрывает HTTP-сессию."""
|
||||
if self._session and not self._session.closed:
|
||||
await self._session.close()
|
||||
self._session = None
|
||||
|
||||
async def create_order(
|
||||
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.
|
||||
POST /v1/orders
|
||||
|
||||
Returns:
|
||||
OrderData dict с полями id, status, paymentLink, amount, currency, etc.
|
||||
"""
|
||||
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:
|
||||
session = await self._get_session()
|
||||
async with session.post(
|
||||
f'{API_BASE_URL}/v1/orders',
|
||||
json=payload,
|
||||
headers=self._get_headers(),
|
||||
) as response:
|
||||
if response.status == 201:
|
||||
data = await response.json(content_type=None)
|
||||
logger.info('RioPay API order created', status_code=response.status, order_id=data.get('id'))
|
||||
return data
|
||||
|
||||
# Ошибка
|
||||
text = await response.text()
|
||||
try:
|
||||
error_data = await response.json(content_type=None)
|
||||
error_msg = error_data.get('message') or error_data.get('error') or text
|
||||
except Exception:
|
||||
error_msg = text
|
||||
|
||||
logger.error('RioPay create_order error', status_code=response.status)
|
||||
logger.debug('RioPay create_order error details', error_msg=error_msg)
|
||||
raise RioPayAPIError(response.status, error_msg)
|
||||
|
||||
except aiohttp.ClientError as e:
|
||||
logger.exception('RioPay API connection error', error=e)
|
||||
raise
|
||||
|
||||
async def get_order(self, order_id: str) -> dict[str, Any]:
|
||||
"""
|
||||
Получает заказ по UUID.
|
||||
GET /v1/orders/{id}
|
||||
"""
|
||||
logger.info('RioPay get_order', order_id=order_id)
|
||||
|
||||
try:
|
||||
session = await self._get_session()
|
||||
async with session.get(
|
||||
f'{API_BASE_URL}/v1/orders/{order_id}',
|
||||
headers=self._get_headers(),
|
||||
) as response:
|
||||
if response.status == 200:
|
||||
return await response.json(content_type=None)
|
||||
|
||||
text = await response.text()
|
||||
logger.error('RioPay get_order error', status_code=response.status)
|
||||
raise RioPayAPIError(response.status, text)
|
||||
|
||||
except aiohttp.ClientError as e:
|
||||
logger.exception('RioPay API connection error', error=e)
|
||||
raise
|
||||
|
||||
def verify_webhook_signature(self, raw_body: bytes, signature: str) -> bool:
|
||||
"""HMAC-SHA512 верификация подписи webhook."""
|
||||
try:
|
||||
expected = hmac.new(
|
||||
self.webhook_secret.encode(),
|
||||
raw_body,
|
||||
hashlib.sha512,
|
||||
).hexdigest()
|
||||
return hmac.compare_digest(expected, signature)
|
||||
except Exception as e:
|
||||
logger.error('RioPay webhook verify error', error=e)
|
||||
return False
|
||||
|
||||
|
||||
# Singleton instance
|
||||
riopay_service = RioPayService()
|
||||
@@ -825,7 +825,13 @@ async def _auto_purchase_tariff(
|
||||
|
||||
await with_admin_notification_service(
|
||||
lambda svc: svc.send_subscription_purchase_notification(
|
||||
db, user, subscription, transaction, period_days, was_trial_conversion
|
||||
db,
|
||||
user,
|
||||
subscription,
|
||||
transaction,
|
||||
period_days,
|
||||
was_trial_conversion,
|
||||
purchase_type='renewal',
|
||||
)
|
||||
)
|
||||
except Exception as error:
|
||||
@@ -1100,7 +1106,13 @@ async def _auto_purchase_daily_tariff(
|
||||
|
||||
await with_admin_notification_service(
|
||||
lambda svc: svc.send_subscription_purchase_notification(
|
||||
db, user, subscription, transaction, 1, was_trial_conversion
|
||||
db,
|
||||
user,
|
||||
subscription,
|
||||
transaction,
|
||||
1,
|
||||
was_trial_conversion,
|
||||
purchase_type='renewal',
|
||||
)
|
||||
)
|
||||
except Exception as error:
|
||||
@@ -1290,6 +1302,7 @@ async def _auto_add_devices(
|
||||
description,
|
||||
create_transaction=True,
|
||||
payment_method=PaymentMethod.BALANCE,
|
||||
transaction_type=TransactionType.SUBSCRIPTION_PAYMENT,
|
||||
)
|
||||
if not success:
|
||||
logger.warning(
|
||||
@@ -1348,7 +1361,7 @@ async def _auto_add_devices(
|
||||
await db.rollback()
|
||||
return False
|
||||
|
||||
# Реактивируем подписку если она была DISABLED (например, после LIMITED в RemnaWave)
|
||||
# Реактивируем подписку если она была DISABLED/EXPIRED (например, после LIMITED/EXPIRED в RemnaWave)
|
||||
from app.database.crud.subscription import reactivate_subscription
|
||||
|
||||
await reactivate_subscription(db, subscription)
|
||||
@@ -1357,6 +1370,9 @@ async def _auto_add_devices(
|
||||
try:
|
||||
subscription_service = SubscriptionService()
|
||||
await subscription_service.update_remnawave_user(db, subscription)
|
||||
# Явно включаем пользователя на панели (PATCH может не снять LIMITED-статус)
|
||||
if getattr(user, 'remnawave_uuid', None) and subscription.status == 'active':
|
||||
await subscription_service.enable_remnawave_user(user.remnawave_uuid)
|
||||
except Exception as error:
|
||||
logger.warning(
|
||||
'⚠️ Автопокупка устройств: не удалось обновить Remnawave для пользователя',
|
||||
@@ -1528,6 +1544,7 @@ async def _auto_add_traffic(
|
||||
description,
|
||||
create_transaction=True,
|
||||
payment_method=PaymentMethod.BALANCE,
|
||||
transaction_type=TransactionType.SUBSCRIPTION_PAYMENT,
|
||||
)
|
||||
if not success:
|
||||
logger.warning(
|
||||
@@ -1559,7 +1576,7 @@ async def _auto_add_traffic(
|
||||
await db.rollback()
|
||||
return False
|
||||
|
||||
# Реактивируем подписку если она была DISABLED (например, после LIMITED в RemnaWave)
|
||||
# Реактивируем подписку если она была DISABLED/EXPIRED (например, после LIMITED/EXPIRED в RemnaWave)
|
||||
from app.database.crud.subscription import reactivate_subscription
|
||||
|
||||
await reactivate_subscription(db, subscription)
|
||||
@@ -1568,6 +1585,9 @@ async def _auto_add_traffic(
|
||||
try:
|
||||
subscription_service = SubscriptionService()
|
||||
await subscription_service.update_remnawave_user(db, subscription)
|
||||
# Явно включаем пользователя на панели (PATCH может не снять LIMITED-статус)
|
||||
if getattr(user, 'remnawave_uuid', None) and subscription.status == 'active':
|
||||
await subscription_service.enable_remnawave_user(user.remnawave_uuid)
|
||||
except Exception as error:
|
||||
logger.warning(
|
||||
'⚠️ Автопокупка трафика: не удалось обновить Remnawave для пользователя',
|
||||
@@ -2398,6 +2418,7 @@ async def auto_purchase_saved_cart_after_topup(
|
||||
transaction,
|
||||
selection.period.days,
|
||||
was_trial_conversion,
|
||||
purchase_type='renewal',
|
||||
)
|
||||
except Exception as error: # pragma: no cover - defensive logging
|
||||
logger.error(
|
||||
|
||||
@@ -527,7 +527,7 @@ class MiniAppSubscriptionPurchaseService:
|
||||
value = int(package.get('gb') or 0)
|
||||
price_per_month = int(package.get('price') or 0)
|
||||
discounted_per_month, discount_value = _apply_percentage_discount(price_per_month, discount_percent)
|
||||
label = texts.format_traffic(value if value else 0)
|
||||
label = texts.format_traffic(value or 0)
|
||||
options.append(
|
||||
PurchaseTrafficOption(
|
||||
value=value,
|
||||
@@ -587,7 +587,7 @@ class MiniAppSubscriptionPurchaseService:
|
||||
options=options,
|
||||
min_selectable=1 if options else 0,
|
||||
max_selectable=len(options),
|
||||
default_selection=default_selection if default_selection else [opt.uuid for opt in options[:1]],
|
||||
default_selection=default_selection or [opt.uuid for opt in options[:1]],
|
||||
hint=None,
|
||||
)
|
||||
|
||||
|
||||
@@ -14,7 +14,6 @@ from app.database.models import PromoGroup, Subscription, SubscriptionStatus, Us
|
||||
from app.external.remnawave_api import RemnaWaveAPI, RemnaWaveAPIError, RemnaWaveUser, TrafficLimitStrategy, UserStatus
|
||||
from app.utils.pricing_utils import (
|
||||
calculate_months_from_days,
|
||||
get_remaining_months,
|
||||
)
|
||||
from app.utils.subscription_utils import (
|
||||
resolve_hwid_device_limit_for_payload,
|
||||
@@ -1406,8 +1405,9 @@ class SubscriptionService:
|
||||
if additional_server_ids is None:
|
||||
additional_server_ids = []
|
||||
|
||||
months_to_pay = get_remaining_months(subscription.end_date)
|
||||
period_hint_days = months_to_pay * 30 if months_to_pay > 0 else None
|
||||
now = datetime.now(UTC)
|
||||
days_to_pay = max(1, (subscription.end_date - now).days)
|
||||
period_hint_days = days_to_pay
|
||||
|
||||
user = getattr(subscription, 'user', None)
|
||||
promo_group = user.promo_group if user else None
|
||||
@@ -1424,16 +1424,14 @@ class SubscriptionService:
|
||||
)
|
||||
traffic_discount_per_month = traffic_price_per_month * traffic_discount_percent // 100
|
||||
discounted_traffic_per_month = traffic_price_per_month - traffic_discount_per_month
|
||||
traffic_total_price = discounted_traffic_per_month * months_to_pay
|
||||
traffic_total_price = int(discounted_traffic_per_month * days_to_pay / 30)
|
||||
total_price += traffic_total_price
|
||||
message = (
|
||||
f'Трафик +{additional_traffic_gb}ГБ: {traffic_price_per_month / 100}₽/мес x {months_to_pay}'
|
||||
f'Трафик +{additional_traffic_gb}ГБ: {traffic_price_per_month / 100}₽/мес x {days_to_pay} дн.'
|
||||
f' = {traffic_total_price / 100}₽'
|
||||
)
|
||||
if traffic_discount_per_month > 0:
|
||||
message += (
|
||||
f' (скидка {traffic_discount_percent}%: -{traffic_discount_per_month * months_to_pay / 100}₽)'
|
||||
)
|
||||
message += f' (скидка {traffic_discount_percent}%: -{int(traffic_discount_per_month * days_to_pay / 30) / 100}₽)'
|
||||
logger.info(message)
|
||||
|
||||
if additional_devices > 0:
|
||||
@@ -1446,16 +1444,14 @@ class SubscriptionService:
|
||||
)
|
||||
devices_discount_per_month = devices_price_per_month * devices_discount_percent // 100
|
||||
discounted_devices_per_month = devices_price_per_month - devices_discount_per_month
|
||||
devices_total_price = discounted_devices_per_month * months_to_pay
|
||||
devices_total_price = int(discounted_devices_per_month * days_to_pay / 30)
|
||||
total_price += devices_total_price
|
||||
message = (
|
||||
f'Устройства +{additional_devices}: {devices_price_per_month / 100}₽/мес x {months_to_pay}'
|
||||
f'Устройства +{additional_devices}: {devices_price_per_month / 100}₽/мес x {days_to_pay} дн.'
|
||||
f' = {devices_total_price / 100}₽'
|
||||
)
|
||||
if devices_discount_per_month > 0:
|
||||
message += (
|
||||
f' (скидка {devices_discount_percent}%: -{devices_discount_per_month * months_to_pay / 100}₽)'
|
||||
)
|
||||
message += f' (скидка {devices_discount_percent}%: -{int(devices_discount_per_month * days_to_pay / 30) / 100}₽)'
|
||||
logger.info(message)
|
||||
|
||||
if additional_server_ids and db:
|
||||
@@ -1473,20 +1469,20 @@ class SubscriptionService:
|
||||
)
|
||||
server_discount_per_month = server_price_per_month * servers_discount_percent // 100
|
||||
discounted_server_per_month = server_price_per_month - server_discount_per_month
|
||||
server_total_price = discounted_server_per_month * months_to_pay
|
||||
server_total_price = int(discounted_server_per_month * days_to_pay / 30)
|
||||
total_price += server_total_price
|
||||
message = (
|
||||
f'Сервер {server.display_name}: {server_price_per_month / 100}₽/мес x {months_to_pay}'
|
||||
f'Сервер {server.display_name}: {server_price_per_month / 100}₽/мес x {days_to_pay} дн.'
|
||||
f' = {server_total_price / 100}₽'
|
||||
)
|
||||
if server_discount_per_month > 0:
|
||||
message += (
|
||||
f' (скидка {servers_discount_percent}%:'
|
||||
f' -{server_discount_per_month * months_to_pay / 100}₽)'
|
||||
f' -{int(server_discount_per_month * days_to_pay / 30) / 100}₽)'
|
||||
)
|
||||
logger.info(message)
|
||||
|
||||
logger.info('Итого доплата за мес: ₽', months_to_pay=months_to_pay, total_price=total_price / 100)
|
||||
logger.info('Итого доплата за дн.: ₽', days_to_pay=days_to_pay, total_price=total_price / 100)
|
||||
return total_price
|
||||
|
||||
def _gb_to_bytes(self, gb: int | None) -> int:
|
||||
@@ -1540,7 +1536,7 @@ class SubscriptionService:
|
||||
logger.warning('Не удалось предзагрузить тариф подписки', subscription_id=sub.id, error=exc)
|
||||
|
||||
# Вычисляем стратегию сброса трафика один раз — все подписки одного тарифа
|
||||
sample_tariff = subscriptions[0].tariff if subscriptions[0].tariff else None
|
||||
sample_tariff = subscriptions[0].tariff or None
|
||||
traffic_strategy = get_traffic_reset_strategy(sample_tariff)
|
||||
|
||||
# Параллельная синхронизация: один API-клиент, только HTTP-вызовы внутри gather
|
||||
|
||||
@@ -88,6 +88,7 @@ class BotConfigurationService:
|
||||
'CLOUDPAYMENTS': '💳 CloudPayments',
|
||||
'FREEKASSA': '💳 Freekassa',
|
||||
'KASSA_AI': '💳 KassaAI',
|
||||
'RIOPAY': '💳 RioPay',
|
||||
'YOOKASSA': '🟣 YooKassa',
|
||||
'PLATEGA': '💳 {platega_name}',
|
||||
'TRIBUTE': '🎁 Tribute',
|
||||
@@ -148,6 +149,7 @@ class BotConfigurationService:
|
||||
'CLOUDPAYMENTS': 'CloudPayments: оплата банковскими картами, Public ID, API Secret и вебхуки.',
|
||||
'FREEKASSA': 'Freekassa: ID магазина, API ключ, секретные слова и вебхуки.',
|
||||
'KASSA_AI': 'KassaAI: отдельная платёжка api.fk.life с СБП, картами и SberPay.',
|
||||
'RIOPAY': 'RioPay: платёжная система api.riopay.online с поддержкой карт и СБП.',
|
||||
'PLATEGA': '{platega_name}: merchant ID, секрет, ссылки возврата и методы оплаты.',
|
||||
'MULENPAY': 'Платежи {mulenpay_name} и параметры магазина.',
|
||||
'PAL24': 'PAL24 / PayPalych подключения и лимиты.',
|
||||
@@ -346,6 +348,7 @@ class BotConfigurationService:
|
||||
'CLOUDPAYMENTS_': 'CLOUDPAYMENTS',
|
||||
'FREEKASSA_': 'FREEKASSA',
|
||||
'KASSA_AI_': 'KASSA_AI',
|
||||
'RIOPAY_': 'RIOPAY',
|
||||
'PLATEGA_': 'PLATEGA',
|
||||
'MULENPAY_': 'MULENPAY',
|
||||
'PAL24_': 'PAL24',
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import asyncio
|
||||
import uuid
|
||||
from datetime import UTC, datetime
|
||||
from typing import Any
|
||||
|
||||
import structlog
|
||||
@@ -113,6 +114,12 @@ class YooKassaService:
|
||||
|
||||
builder.set_receipt(receipt_data_dict)
|
||||
|
||||
# Рекуррентные платежи: сохранение карты
|
||||
if settings.YOOKASSA_RECURRENT_ENABLED:
|
||||
if settings.YOOKASSA_RECURRENT_REQUIRED:
|
||||
builder.set_save_payment_method(True)
|
||||
# Если не required — не устанавливаем, YooKassa покажет чекбокс
|
||||
|
||||
idempotence_key = str(uuid.uuid4())
|
||||
payment_request = builder.build()
|
||||
|
||||
@@ -308,6 +315,21 @@ class YooKassaService:
|
||||
'payment_method_type': payment_info_yk.payment_method.type
|
||||
if payment_info_yk.payment_method
|
||||
else None,
|
||||
'payment_method_id': payment_info_yk.payment_method.id if payment_info_yk.payment_method else None,
|
||||
'payment_method_saved': payment_info_yk.payment_method.saved
|
||||
if payment_info_yk.payment_method and hasattr(payment_info_yk.payment_method, 'saved')
|
||||
else False,
|
||||
'payment_method_card': {
|
||||
'first6': payment_info_yk.payment_method.card.first6,
|
||||
'last4': payment_info_yk.payment_method.card.last4,
|
||||
'card_type': payment_info_yk.payment_method.card.card_type,
|
||||
'expiry_month': payment_info_yk.payment_method.card.expiry_month,
|
||||
'expiry_year': payment_info_yk.payment_method.card.expiry_year,
|
||||
}
|
||||
if payment_info_yk.payment_method
|
||||
and hasattr(payment_info_yk.payment_method, 'card')
|
||||
and payment_info_yk.payment_method.card
|
||||
else None,
|
||||
'test_mode': payment_info_yk.test if hasattr(payment_info_yk, 'test') else None,
|
||||
}
|
||||
logger.warning('Платеж не найден в YooKassa ID', payment_id_in_yookassa=payment_id_in_yookassa)
|
||||
@@ -326,3 +348,104 @@ class YooKassaService:
|
||||
exc_info=True,
|
||||
)
|
||||
return None
|
||||
|
||||
async def create_autopayment(
|
||||
self,
|
||||
amount: float,
|
||||
currency: str,
|
||||
description: str,
|
||||
payment_method_id: str,
|
||||
metadata: dict[str, Any],
|
||||
receipt_email: str | None = None,
|
||||
receipt_phone: str | None = None,
|
||||
idempotence_key: str | None = None,
|
||||
) -> dict[str, Any] | None:
|
||||
"""Создаёт рекуррентный автоплатёж через сохранённый payment_method_id (без confirmation)."""
|
||||
|
||||
if not self.configured:
|
||||
logger.error('YooKassa не сконфигурирован. Невозможно создать автоплатёж.')
|
||||
return None
|
||||
|
||||
customer_contact_for_receipt = {}
|
||||
if receipt_email:
|
||||
customer_contact_for_receipt['email'] = receipt_email
|
||||
elif receipt_phone:
|
||||
customer_contact_for_receipt['phone'] = receipt_phone
|
||||
elif hasattr(settings, 'YOOKASSA_DEFAULT_RECEIPT_EMAIL') and settings.YOOKASSA_DEFAULT_RECEIPT_EMAIL:
|
||||
customer_contact_for_receipt['email'] = settings.YOOKASSA_DEFAULT_RECEIPT_EMAIL
|
||||
else:
|
||||
logger.error(
|
||||
'КРИТИЧНО: Не предоставлен email/телефон для чека автоплатежа и YOOKASSA_DEFAULT_RECEIPT_EMAIL не установлен.'
|
||||
)
|
||||
return None
|
||||
|
||||
try:
|
||||
builder = PaymentRequestBuilder()
|
||||
builder.set_amount({'value': str(round(amount, 2)), 'currency': currency.upper()})
|
||||
builder.set_capture(True)
|
||||
builder.set_payment_method_id(payment_method_id)
|
||||
builder.set_description(description)
|
||||
builder.set_metadata(metadata)
|
||||
|
||||
receipt_items_list: list[dict[str, Any]] = [
|
||||
{
|
||||
'description': description[:128],
|
||||
'quantity': '1.00',
|
||||
'amount': {'value': str(round(amount, 2)), 'currency': currency.upper()},
|
||||
'vat_code': str(getattr(settings, 'YOOKASSA_VAT_CODE', 1)),
|
||||
'payment_mode': getattr(settings, 'YOOKASSA_PAYMENT_MODE', 'full_payment'),
|
||||
'payment_subject': getattr(settings, 'YOOKASSA_PAYMENT_SUBJECT', 'service'),
|
||||
}
|
||||
]
|
||||
receipt_data_dict: dict[str, Any] = {'customer': customer_contact_for_receipt, 'items': receipt_items_list}
|
||||
builder.set_receipt(receipt_data_dict)
|
||||
|
||||
if not idempotence_key:
|
||||
sub_id = metadata.get('subscription_id', uuid.uuid4())
|
||||
idempotence_key = f'autopay_{sub_id}_{datetime.now(UTC).strftime("%Y-%m-%d")}'
|
||||
payment_request = builder.build()
|
||||
|
||||
logger.info(
|
||||
'Создание автоплатежа YooKassa',
|
||||
amount=amount,
|
||||
currency=currency,
|
||||
payment_method_id=payment_method_id,
|
||||
metadata=metadata,
|
||||
idempotence_key=idempotence_key,
|
||||
)
|
||||
|
||||
loop = asyncio.get_running_loop()
|
||||
response = await loop.run_in_executor(
|
||||
None, lambda: YooKassaPayment.create(payment_request, idempotence_key)
|
||||
)
|
||||
|
||||
logger.info(
|
||||
'Ответ YooKassa автоплатёж',
|
||||
response_id=response.id,
|
||||
status=response.status,
|
||||
paid=response.paid,
|
||||
)
|
||||
|
||||
return {
|
||||
'id': response.id,
|
||||
'status': response.status,
|
||||
'paid': response.paid,
|
||||
'metadata': response.metadata,
|
||||
'amount_value': float(response.amount.value),
|
||||
'amount_currency': response.amount.currency,
|
||||
'idempotence_key_used': idempotence_key,
|
||||
'refundable': response.refundable,
|
||||
'created_at': response.created_at.isoformat()
|
||||
if hasattr(response.created_at, 'isoformat')
|
||||
else str(response.created_at),
|
||||
'description_from_yk': response.description,
|
||||
'test_mode': response.test if hasattr(response, 'test') else None,
|
||||
}
|
||||
except Exception as e:
|
||||
logger.error(
|
||||
'Ошибка создания автоплатежа YooKassa',
|
||||
payment_method_id=payment_method_id,
|
||||
error=e,
|
||||
exc_info=True,
|
||||
)
|
||||
return None
|
||||
|
||||
@@ -2,7 +2,6 @@ from .pricing_utils import (
|
||||
calculate_months_from_days,
|
||||
calculate_prorated_price,
|
||||
format_period_description,
|
||||
get_remaining_months,
|
||||
)
|
||||
|
||||
|
||||
@@ -10,5 +9,4 @@ __all__ = [
|
||||
'calculate_months_from_days',
|
||||
'calculate_prorated_price',
|
||||
'format_period_description',
|
||||
'get_remaining_months',
|
||||
]
|
||||
|
||||
@@ -159,6 +159,18 @@ def get_available_payment_methods() -> list[dict[str, str]]:
|
||||
}
|
||||
)
|
||||
|
||||
if settings.is_riopay_enabled():
|
||||
riopay_name = settings.get_riopay_display_name()
|
||||
methods.append(
|
||||
{
|
||||
'id': 'riopay',
|
||||
'name': f'Банковская карта ({riopay_name})',
|
||||
'icon': '💳',
|
||||
'description': f'через {riopay_name}',
|
||||
'callback': 'topup_riopay',
|
||||
}
|
||||
)
|
||||
|
||||
if settings.is_support_topup_enabled():
|
||||
methods.append(
|
||||
{
|
||||
@@ -276,6 +288,8 @@ def is_payment_method_available(method_id: str) -> bool:
|
||||
return settings.is_freekassa_enabled()
|
||||
if method_id == 'kassa_ai':
|
||||
return settings.is_kassa_ai_enabled()
|
||||
if method_id == 'riopay':
|
||||
return settings.is_riopay_enabled()
|
||||
if method_id == 'support':
|
||||
return settings.is_support_topup_enabled()
|
||||
return False
|
||||
|
||||
+15
-19
@@ -18,15 +18,6 @@ def calculate_months_from_days(days: int) -> int:
|
||||
return max(1, round(days / 30))
|
||||
|
||||
|
||||
def get_remaining_months(end_date: datetime) -> int:
|
||||
current_time = datetime.now(UTC)
|
||||
if end_date <= current_time:
|
||||
return 1
|
||||
|
||||
remaining_days = (end_date - current_time).days
|
||||
return max(1, round(remaining_days / 30))
|
||||
|
||||
|
||||
def calculate_period_multiplier(period_days: int) -> tuple[int, float]:
|
||||
exact_months = period_days / 30
|
||||
months_count = max(1, round(exact_months))
|
||||
@@ -41,20 +32,28 @@ def calculate_period_multiplier(period_days: int) -> tuple[int, float]:
|
||||
return months_count, exact_months
|
||||
|
||||
|
||||
def calculate_prorated_price(monthly_price: int, end_date: datetime, min_charge_months: int = 1) -> tuple[int, int]:
|
||||
months_remaining = get_remaining_months(end_date)
|
||||
months_to_charge = max(min_charge_months, months_remaining)
|
||||
def calculate_prorated_price(monthly_price: int, end_date: datetime, min_charge_days: int = 30) -> tuple[int, int]:
|
||||
"""Calculate prorated price based on remaining days.
|
||||
|
||||
total_price = monthly_price * months_to_charge
|
||||
Returns:
|
||||
tuple of (total_price_kopeks, days_charged)
|
||||
"""
|
||||
now = datetime.now(UTC)
|
||||
days_remaining = max(1, (end_date - now).days)
|
||||
days_to_charge = max(min_charge_days, days_remaining)
|
||||
|
||||
total_price = int(monthly_price * days_to_charge / 30)
|
||||
if monthly_price > 0:
|
||||
total_price = max(100, total_price) # Минимум 1 рубль
|
||||
|
||||
logger.debug(
|
||||
'Расчет пропорциональной цены: ₽/мес × мес ₽',
|
||||
'Расчет пропорциональной цены: ₽/мес × дн./30 = ₽',
|
||||
monthly_price=monthly_price / 100,
|
||||
months_to_charge=months_to_charge,
|
||||
days_to_charge=days_to_charge,
|
||||
total_price=total_price / 100,
|
||||
)
|
||||
|
||||
return total_price, months_to_charge
|
||||
return total_price, days_to_charge
|
||||
|
||||
|
||||
def apply_percentage_discount(amount: int, percent: int) -> tuple[int, int]:
|
||||
@@ -156,7 +155,6 @@ async def compute_simple_subscription_price(
|
||||
period_days=period_days,
|
||||
)
|
||||
base_discount = base_price_original * period_discount_percent // 100
|
||||
base_price_original - base_discount
|
||||
|
||||
traffic_discount_percent = resolve_discount_percent(
|
||||
user,
|
||||
@@ -165,7 +163,6 @@ async def compute_simple_subscription_price(
|
||||
period_days=period_days,
|
||||
)
|
||||
traffic_discount = traffic_price_original * traffic_discount_percent // 100
|
||||
traffic_price_original - traffic_discount
|
||||
|
||||
devices_discount_percent = resolve_discount_percent(
|
||||
user,
|
||||
@@ -174,7 +171,6 @@ async def compute_simple_subscription_price(
|
||||
period_days=period_days,
|
||||
)
|
||||
devices_discount = devices_price_original * devices_discount_percent // 100
|
||||
devices_price_original - devices_discount
|
||||
|
||||
servers_discount_percent = resolve_discount_percent(
|
||||
user,
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user